# core/hermes_loop.py """ Hermès Loop v3 – avec Diffusion, Exploration (Arbre d’Hypothèses) et parsing JSON robuste. """ import logging import json import re import time import asyncio from typing import Optional, Dict, List, Any from core.hypothesis_tree import HypothesisTree, BranchStatus from core.branch_executor import BranchExecutor from core.error_tree import ErrorTree logger = logging.getLogger("vortex.hermes") class HermesLoop: """ Cycle Hermès en 7 étapes classiques + exploration d’hypothèses. """ def __init__(self, llm, memory, error_tree: ErrorTree, semantic_router, vector_memory=None, diffusion_passes: int = 2): self.llm = llm self.memory = memory self.error_tree = error_tree self.semantic_router = semantic_router self.vector_memory = vector_memory self.diffusion_passes = diffusion_passes # ─── DIFFUSION (auto‑correction) ──────────────────────────── async def _diffuse(self, text: str, task: str, passes: int = None) -> str: """Relit et corrige un texte en plusieurs passes.""" if passes is None: passes = self.diffusion_passes current = text for p in range(1, passes + 1): prompt = f""" Tu es un relecteur expert. Voici une réponse à la tâche : "{task}". RÉPONSE ACTUELLE (passe {p}/{passes}) : {current} INSTRUCTIONS : 1. Corrige les erreurs de syntaxe ou de logique. 2. Améliore la clarté et la documentation. 3. Ajoute des tests ou des vérifications si nécessaire. 4. Si parfaite, recopie‑la à l'identique. RENDS UNIQUEMENT LA VERSION RÉVISÉE. """ try: resp = await self.llm.call( agent="Hermès-Diffusion", system="Tu es un relecteur méticuleux.", user=prompt ) new_text = resp.content if hasattr(resp, 'content') else str(resp) if new_text and new_text != current and len(new_text) > 20: current = new_text logger.info("[Diffusion] Passe %d : amélioration appliquée.", p) else: logger.info("[Diffusion] Passe %d : aucune amélioration, arrêt.", p) break except Exception as e: logger.warning(f"[Diffusion] Échec passe {p} : {e}") break return current # ─── EXPLORATION D’HYPOTHÈSES (Arbre) ───────────────────── async def explore_hypotheses(self, task: str, n_hypotheses: int = 3) -> Dict: """ Génère plusieurs hypothèses, les exécute en parallèle, et retourne la meilleure. """ logger.info(f"[Hermès] Exploration d'hypothèses pour: {task[:80]}...") # 1. Générer les hypothèses via le LLM hypotheses = await self._generate_hypotheses(task, n_hypotheses) # 2. Construire l'arbre tree = HypothesisTree(task, max_branches=n_hypotheses) for hyp in hypotheses: description = hyp.get("description", "Aucune description") code = hyp.get("code", "") tree.add_branch(description, code=code) # 3. Exécuter chaque branche en parallèle executor = BranchExecutor(timeout=30) tasks = [] for branch in tree.branches.values(): tasks.append(executor.execute_branch(branch, tree)) results = await asyncio.gather(*tasks) # 4. Sélectionner la meilleure branche best = tree.get_best_branch() if best: logger.info(f"[Hermès] Meilleure hypothèse: {best.id} (score={best.score:.2f})") # Capitaliser les échecs dans Harbour for branch in tree.branches.values(): if branch.status == BranchStatus.FAILED and branch.error: self.error_tree.add_error(branch.error, branch.hypothesis) # Indexation vectorielle du résultat if self.vector_memory: try: self.vector_memory.add_episode( f"Exploration: {task}\nMeilleure hypothèse: {best.hypothesis}\nRésultat: {best.result}", metadata={"source": "explore", "score": best.score} ) except Exception as e: logger.warning(f"[Hermès] Indexation échouée: {e}") return { "task": task, "method": "Hermès/Exploration", "best_branch": best.id, "best_score": round(best.score, 3), "best_result": best.result, "summary": tree.get_summary(), "branches": [ {"id": b.id, "hypothesis": b.hypothesis[:100], "score": b.score, "status": b.status.value} for b in tree.branches.values() ] } else: return {"task": task, "error": "Aucune branche réussie"} async def _generate_hypotheses(self, task: str, n: int) -> List[Dict]: """ Demande au LLM de générer n approches différentes, avec parsing JSON robuste. """ prompt = f""" Tâche : {task} Génère précisément {n} approches différentes pour résoudre ce problème. Pour chaque approche, donne une **description concise** et un **code Python** (si applicable). Format de sortie : UNIQUEMENT un JSON valide, sans texte avant ou après. Le JSON doit être une liste de {n} dicts contenant "description" et "code". Exemple : [ {{"description": "Approche avec heaps", "code": "import heapq\\n..."}}, {{"description": "Approche avec deque triée", "code": "..."}} ] """ try: resp = await self.llm.call( agent="Hermès-Explorateur", system="Tu es un explorateur de solutions. Génère des approches variées et originales. Réponds uniquement en JSON valide.", user=prompt ) content = resp.content if hasattr(resp, 'content') else str(resp) # Nettoyer les backticks et autres content = content.strip() content = re.sub(r'^```(?:json)?\s*', '', content) content = re.sub(r'\s*```$', '', content) # Essayer de parser le JSON try: data = json.loads(content) if isinstance(data, list) and len(data) >= n: return data[:n] elif isinstance(data, list): # Si moins de n, compléter avec des descriptions génériques missing = n - len(data) for i in range(missing): data.append({"description": f"Approche supplémentaire {i+1}", "code": ""}) return data except json.JSONDecodeError: # Fallback : extraire les éléments séparés par des numéros ou des tirets lines = content.split('\n') hypotheses = [] current = {"description": "", "code": ""} for line in lines: if re.match(r'^\s*(\d+\.|\-)\s*', line): if current["description"]: hypotheses.append(current) current = {"description": "", "code": ""} current["description"] = re.sub(r'^\s*(\d+\.|\-)\s*', '', line).strip() elif current["description"] and line.strip(): if "```" in line: if current["code"]: current["code"] += "\n" + line else: current["code"] = line else: current["description"] += " " + line.strip() if current["description"]: hypotheses.append(current) # Si on n'a pas assez, on ajoute des placeholders while len(hypotheses) < n: hypotheses.append({"description": f"Approche alternative {len(hypotheses)+1}", "code": ""}) return hypotheses[:n] except Exception as e: logger.error(f"[Hermès] Échec génération hypothèses: {e}") # Fallback : générer n hypothèses génériques return [{"description": f"Approche {i+1} (générique)", "code": ""} for i in range(n)] # ─── CYCLE HERMÈS CLASSIQUE (7 étapes) ──────────────────── async def run(self, task: str) -> Dict: """Cycle complet en 7 étapes, avec diffusion sur l’étape Act.""" t0 = time.time() logger.info(f"[Hermès] Démarrage : {task[:80]}...") # Contexte RAG vector_context = "" if self.vector_memory and hasattr(self.vector_memory, "search_context_block"): try: vector_context = self.vector_memory.search_context_block(task, n=3, max_chars=800) if vector_context: logger.info("[Hermès] Contexte vectoriel récupéré (%d caractères)", len(vector_context)) except Exception as e: logger.warning(f"[Hermès] Échec RAG : {e}") steps = [] # 1. ANCRER anchor = await self._anchor(task, vector_context) steps.append({"step": "anchor", "action": "Cadrer le problème", "result": anchor[:300], "score": 0.9}) # 2. RAISONNER reasoning = await self._reason(task, anchor, vector_context) steps.append({"step": "reason", "action": "Explorer les approches", "result": reasoning[:300], "score": 0.85}) # 3. AGIR (avec DIFFUSION) raw_solution = await self._act(task, reasoning, vector_context) diffused_solution = await self._diffuse(raw_solution, task, passes=self.diffusion_passes) steps.append({"step": "act", "action": "Produire la solution", "result": diffused_solution[:300], "score": 0.85}) # 4. OBSERVER observation = await self._observe(task, diffused_solution) steps.append({"step": "observe", "action": "Capturer le résultat", "result": observation[:300], "score": 0.825}) # 5. RÉÉVALUER reeval = await self._reevaluate(task, diffused_solution, observation) steps.append({"step": "reeval", "action": "Comparer à l'attendu", "result": reeval[:300], "score": 0.7}) # 6. VÉRIFIER (avec diffusion sur les tests) raw_tests = await self._verify(task, diffused_solution) diffused_tests = await self._diffuse(raw_tests, f"Tests pour : {task}", passes=1) steps.append({"step": "verify", "action": "Valider formellement", "result": diffused_tests[:300], "score": 0.6}) # 7. NARRER narration = await self._narrate(task, diffused_solution, diffused_tests, steps) steps.append({"step": "narrate", "action": "Documenter et livrer", "result": narration[:300], "score": 0.9}) overall_score = sum(s["score"] for s in steps) / len(steps) elapsed = time.time() - t0 logger.info(f"[Hermès] Terminé en {elapsed:.1f}s, score={overall_score:.3f}") # Indexation automatique if self.vector_memory and hasattr(self.vector_memory, "add_hermes_result"): try: self.vector_memory.add_hermes_result({ "task": task, "narration": narration, "deliverable": diffused_solution, "overall_score": overall_score }) except Exception as e: logger.warning(f"[Hermès] Indexation échouée : {e}") return { "task": task, "method": "Hermès/Fable", "elapsed_s": round(elapsed, 2), "overall_score": round(overall_score, 3), "steps": steps, "deliverable": diffused_solution, "narration": narration, "used_vector_context": bool(vector_context) } # ─── SOUS‑ÉTAPES (appels async) ──────────────────────────── async def _anchor(self, task: str, vector_context: str) -> str: prompt = f"Tâche : {task}\nContexte RAG : {vector_context[:600]}\nDéfinis le problème, les contraintes et les critères de succès." resp = await self.llm.call(agent="Hermès", system="Tu es un chef de projet.", user=prompt) return resp.content if hasattr(resp, 'content') else str(resp) async def _reason(self, task: str, anchor: str, vector_context: str) -> str: prompt = f"Tâche : {task}\nCadre : {anchor}\nContexte : {vector_context[:400]}\nExplore 3 approches possibles, explique avantages/inconvénients." resp = await self.llm.call(agent="Hermès", system="Tu es un architecte technique.", user=prompt) return resp.content if hasattr(resp, 'content') else str(resp) async def _act(self, task: str, reasoning: str, vector_context: str) -> str: prompt = f"Tâche : {task}\nRaisonnement : {reasoning}\nContexte : {vector_context[:400]}\nProduis une solution complète en Python avec code, docstrings et exemples." resp = await self.llm.call(agent="Hermès", system="Tu es un développeur senior.", user=prompt) return resp.content if hasattr(resp, 'content') else str(resp) async def _observe(self, task: str, solution: str) -> str: prompt = f"Tâche : {task}\nSolution : {solution}\nExtrais les métriques, logs et résultats observables." resp = await self.llm.call(agent="Hermès", system="Tu es un analyste.", user=prompt) return resp.content if hasattr(resp, 'content') else str(resp) async def _reevaluate(self, task: str, solution: str, observation: str) -> str: prompt = f"Tâche : {task}\nSolution : {solution}\nObservation : {observation}\nÉcarts par rapport à l'attendu ? Que faudrait‑il améliorer ?" resp = await self.llm.call(agent="Hermès", system="Tu es un expert en qualité.", user=prompt) return resp.content if hasattr(resp, 'content') else str(resp) async def _verify(self, task: str, solution: str) -> str: prompt = f"Tâche : {task}\nSolution : {solution}\nGénère des tests unitaires (unittest ou pytest) pour valider cette solution." resp = await self.llm.call(agent="Hermès", system="Tu es un ingénieur de test.", user=prompt) return resp.content if hasattr(resp, 'content') else str(resp) async def _narrate(self, task: str, solution: str, tests: str, steps: List) -> str: prompt = f"Tâche : {task}\nSolution finale : {solution[:500]}\nTests : {tests[:300]}\nRésumé des étapes : {json.dumps(steps, default=str)[:400]}\nRédige un compte‑rendu structuré avec recommandations." resp = await self.llm.call(agent="Hermès", system="Tu es un rédacteur technique.", user=prompt) return resp.content if hasattr(resp, 'content') else str(resp)