From f1dee2aa3851365fa6ce01945a71f90567aea9c4 Mon Sep 17 00:00:00 2001 From: WarframeBuilder Date: Fri, 1 May 2026 05:40:17 +0200 Subject: [PATCH] add cortex chat intent guardrails --- scripts/brain/cortex_intent.py | 120 +++++ scripts/brain/dashboard/serve.py | 783 ++++++++++++++++++------------- 2 files changed, 585 insertions(+), 318 deletions(-) create mode 100644 scripts/brain/cortex_intent.py diff --git a/scripts/brain/cortex_intent.py b/scripts/brain/cortex_intent.py new file mode 100644 index 0000000000..f943818830 --- /dev/null +++ b/scripts/brain/cortex_intent.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import List + + +@dataclass +class CortexIntent: + intent: str + confidence: str = "medium" + route_reason: str = "default" + needs_web_search: bool = False + needs_vault_search: bool = False + dashboard_context: str = "" + + def get(self, key, default=None): + return getattr(self, key, default) + + +DASHBOARD_CONTEXT = """Contexte dashboard Cortex: +- URL locale: http://127.0.0.1:8765/ +- Dashboard GPU/brain: brain_gpu.html +- Sidecar chat à droite +- Onglet Playtest +- Onglet Consortium +- APIs utiles: /api/cortex/judges, /api/cortex/homeostasis, /api/chat +""" + + +def _norm(text: str) -> str: + return (text or "").lower().strip() + + +def detect_intent(message: str) -> CortexIntent: + m = _norm(message) + + if any(k in m for k in [ + "recherche web", "cherche sur le web", "actualité", "actualités", + "actu", "news", "récent", "récente", "aujourd'hui", "maintenant", + "dernières nouvelles", "latest" + ]): + return CortexIntent( + intent="recent_web_search", + confidence="high", + route_reason="needs_web_search", + needs_web_search=True, + ) + + if any(k in m for k in [ + "playtest", "playtest intégré", "dashboard", "brain_gpu", + "sidecar", "consortium", "juges", "judges", "homeostasis" + ]): + return CortexIntent( + intent="playtest_dashboard_help", + confidence="high", + route_reason="dashboard_context_injected", + dashboard_context=DASHBOARD_CONTEXT, + ) + + if any(k in m for k in [ + "vault", "mémoire", "memoire", "obsidian", "souviens", + "tu vois le projet", "projet de site", "site comores", "comores", + "workspace", "fichier local", "dans le repo", "dans les fichiers" + ]): + return CortexIntent( + intent="local_project_search", + confidence="high", + route_reason="needs_vault_or_file_search", + needs_vault_search=True, + ) + + if any(k in m for k in [ + "debug", "raisonne", "analyse profonde", "planifie", + "architecture", "pourquoi", "diagnostic" + ]): + return CortexIntent( + intent="deep_reason", + confidence="medium", + route_reason="deep_reason_requested", + ) + + if any(k in m for k in [ + "tu es qui", "présente toi", "présente-toi", "qui es-tu", + "c'est quoi cortex", "qui t'a créé" + ]): + return CortexIntent( + intent="identity", + confidence="high", + route_reason="identity_or_presentation", + ) + + if any(k in m for k in [ + "code", "patch", "modifie", "corrige", "commande", + "powershell", "python", "git" + ]): + return CortexIntent( + intent="code_task", + confidence="medium", + route_reason="code_or_task_execution", + ) + + return CortexIntent( + intent="simple_chat", + confidence="medium", + route_reason="simple_chat", + ) + + +def metadata(intent: CortexIntent, backend: str = "minimax_fast", tools_used: List[str] | None = None, evidence_count: int = 0) -> dict: + return { + "intent": intent.intent, + "tools_used": tools_used or [], + "evidence_count": evidence_count, + "backend": backend, + "route_reason": intent.route_reason, + "confidence": intent.confidence, + "needs_web_search": intent.needs_web_search, + "needs_vault_search": intent.needs_vault_search, + } diff --git a/scripts/brain/dashboard/serve.py b/scripts/brain/dashboard/serve.py index 0fd7992c07..eac57e9f5f 100644 --- a/scripts/brain/dashboard/serve.py +++ b/scripts/brain/dashboard/serve.py @@ -1,12 +1,12 @@ """ -serve.py — Tiny HTTP server that exposes the brain state as JSON for the +serve.py — Tiny HTTP server that exposes the brain state as JSON for the live HTML dashboard. Reads from the vault's existing artefacts; no DB, no caching beyond file mtime. Endpoints: - GET / → dashboard HTML - GET /api/state → current snapshot (graph + activity + resources) - GET /api/state?delta=true → minimal delta since last call (active nodes only) + GET / → dashboard HTML + GET /api/state → current snapshot (graph + activity + resources) + GET /api/state?delta=true → minimal delta since last call (active nodes only) Default port: 8765. Localhost-only (no exposure). """ @@ -56,10 +56,10 @@ def _append_jsonl(path: Path, entry: dict): with open(path, "a", encoding="utf-8") as f: f.write(json.dumps(entry, ensure_ascii=False) + "\n") -# Heartbeat global : timestamp de démarrage du serveur (pour uptime) +# Heartbeat global : timestamp de démarrage du serveur (pour uptime) SERVER_STARTED_AT = time.time() -# Configuration heartbeat éditable (persistée sur disque) — Sam peut modifier ces +# Configuration heartbeat éditable (persistée sur disque) — Sam peut modifier ces # valeurs depuis l'UI en cliquant sur la chip Live. HEARTBEAT_CONFIG_FILE = Path(r"H:\Code\Paperclip\.cortex-heartbeat-config.json") HEARTBEAT_CONFIG_DEFAULTS = { @@ -97,16 +97,16 @@ def _save_heartbeat_config(updates: dict) -> dict: return {"ok": False, "error": str(e), "config": cfg} return {"ok": True, "config": cfg} -# Historique des durées de chat — pour prédire l'ETA du prochain (p50/p90) +# Historique des durées de chat — pour prédire l'ETA du prochain (p50/p90) import collections as _coll CHAT_DURATIONS = _coll.deque(maxlen=20) CHAT_LAST_DONE_TS = 0.0 -# Tracker de progression pour les tooltips temps réel. -# Chaque clé garde un deque (ts, value) — snapshots toutes ~60 s par background thread. -# Fournit deltas 1h/24h pour montrer l'évolution sur les chips topbar. +# Tracker de progression pour les tooltips temps réel. +# Chaque clé garde un deque (ts, value) — snapshots toutes ~60 s par background thread. +# Fournit deltas 1h/24h pour montrer l'évolution sur les chips topbar. PROGRESSION = { - "vault_sem": _coll.deque(maxlen=2880), # 48 h à 1 snap/min + "vault_sem": _coll.deque(maxlen=2880), # 48 h à 1 snap/min "vault_ep": _coll.deque(maxlen=2880), "llm_winner": _coll.deque(maxlen=2880), # backend gagnant courant "cpu": _coll.deque(maxlen=2880), @@ -150,8 +150,8 @@ def _progression_snapshot_loop(): sample["hebbian_total"] = a.get("n_edges_total", 0) sample["n_pulses"] = a.get("cum_pulses", 0) except Exception: pass - # Chats / emergences cumulés - sample["n_chats"] = len(CHAT_DURATIONS) # approx — count last 20 + # Chats / emergences cumulés + sample["n_chats"] = len(CHAT_DURATIONS) # approx — count last 20 try: stream_file = EMERGENCE_STREAM_FILE n_em = 0 @@ -165,7 +165,7 @@ def _progression_snapshot_loop(): except Exception: pass sample["n_emergences"] = n_em except Exception: pass - # LLM gagnant courant — lit le dernier round du benchmark + # LLM gagnant courant — lit le dernier round du benchmark try: bf = VAULT / ".vault-llm-benchmark-iag.json" if bf.exists(): @@ -182,13 +182,13 @@ def _progression_snapshot_loop(): threading.Thread(target=_progression_snapshot_loop, daemon=True).start() -# Tracker de progression du pipeline /api/chat — déterministe, lu par /api/cortex/think_status. -# Chaque étape est posée explicitement par le handler (pas de simulation). +# Tracker de progression du pipeline /api/chat — déterministe, lu par /api/cortex/think_status. +# Chaque étape est posée explicitement par le handler (pas de simulation). CHAT_PROGRESS = {"req_id": None, "stages": [], "started": 0, "done": False} CHAT_PROGRESS_LOCK = threading.Lock() def _chat_stage(req_id: str, name: str, detail: str = ""): - """Pose une étape de progression réelle pour une requête /api/chat.""" + """Pose une étape de progression réelle pour une requête /api/chat.""" if not req_id: return with CHAT_PROGRESS_LOCK: if CHAT_PROGRESS.get("req_id") != req_id: @@ -196,7 +196,7 @@ def _chat_stage(req_id: str, name: str, detail: str = ""): CHAT_PROGRESS["started"] = time.time() CHAT_PROGRESS["stages"] = [] CHAT_PROGRESS["done"] = False - # Ferme l'étape précédente + # Ferme l'étape précédente if CHAT_PROGRESS["stages"]: CHAT_PROGRESS["stages"][-1]["ended"] = time.time() CHAT_PROGRESS["stages"].append({ @@ -234,19 +234,19 @@ def lm_studio_running() -> bool: return False def ensure_lm_studio() -> bool: - """Lance LM Studio si pas déjà actif. Retourne True si prêt.""" + """Lance LM Studio si pas déjà actif. Retourne True si prêt.""" if lm_studio_running(): return True if not LM_STUDIO_EXE.exists(): return False import subprocess as _sp - print("[cortex] LM Studio non actif — lancement auto...", flush=True) + print("[cortex] LM Studio non actif — lancement auto...", flush=True) _sp.Popen([str(LM_STUDIO_EXE)], creationflags=_sp.CREATE_NO_WINDOW if hasattr(_sp, "CREATE_NO_WINDOW") else 0) - # Attendre jusqu'à 45s que l'API soit disponible + # Attendre jusqu'à 45s que l'API soit disponible for _ in range(45): time.sleep(1) if lm_studio_running(): - print("[cortex] LM Studio prêt.", flush=True) + print("[cortex] LM Studio prêt.", flush=True) return True print("[cortex] LM Studio timeout.", flush=True) return False @@ -254,7 +254,7 @@ ORG_UUID = "952c1bc7-5fd1-4f7c-83db-a020932db2ab" _metrics_cache: dict = {"data": None, "ts": 0.0} def _get_session_key() -> str: - """Lit le sessionKey depuis le fichier sauvegardé.""" + """Lit le sessionKey depuis le fichier sauvegardé.""" if COOKIES_FILE.exists(): try: return json.loads(COOKIES_FILE.read_text(encoding="utf-8")).get("sessionKey", "") @@ -295,7 +295,7 @@ def get_metrics() -> dict: except Exception: pass - # Usage — sessionKey seul suffit avec headers browser-like + # Usage — sessionKey seul suffit avec headers browser-like usage = None try: sk = _get_session_key() @@ -415,8 +415,8 @@ def _read_activity() -> dict: class Handler(http.server.SimpleHTTPRequestHandler): - # ─── Protection réseau Windows ────────────────────────────────────────── - # Sur Windows, un client (browser) qui annule un poll en cours déclenche + # ─── Protection réseau Windows ────────────────────────────────────────── + # Sur Windows, un client (browser) qui annule un poll en cours déclenche # WinError 10053 (ConnectionAborted) ou 10054 (ConnectionReset). Ces # erreurs remontaient avant jusqu'au handler global et faisaient bruiter # les logs. On les attrape silencieusement : ce sont des cas normaux, @@ -427,7 +427,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError): self.close_connection = True except Exception as e: - # Évite que le thread du serveur meure sur n'importe quelle exception + # Évite que le thread du serveur meure sur n'importe quelle exception # (le ThreadingTCPServer relance, mais autant logger proprement) try: import traceback as _tb @@ -442,7 +442,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): except Exception: pass def log_error(self, format, *args): - # Silence les erreurs réseau Windows banales (10053, 10054, BrokenPipe) + # Silence les erreurs réseau Windows banales (10053, 10054, BrokenPipe) try: msg = format % args if any(s in str(msg) for s in ('10053', '10054', '10038', @@ -453,7 +453,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): super().log_error(format, *args) def _safe_send_error(self, code: int, message: str = ""): - """send_error qui ne crash pas si le client a fermé la connexion.""" + """send_error qui ne crash pas si le client a fermé la connexion.""" try: self.send_error(code, message) except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError, OSError): @@ -545,72 +545,111 @@ class Handler(http.server.SimpleHTTPRequestHandler): msg = body.get("message", "") msg_lower = msg.lower() - # ── Détection du rôle : vault_search / code / general ── - VAULT_KW = ["vault", "note", "notes", "brain", "cerveau", "mémoire", "memory", - "souvenir", "ingested", "benchmark", "score", "résultat", "result", - "papier", "research", "article", "papers"] - CODE_KW = ["code", "fonction", "function", "class", "refactor", "bug", "fix", - "implement", "implémente", "debug", "stack", "trace", "erreur python"] - role = "general" - if any(k in msg_lower for k in VAULT_KW): - role = "vault_search" - elif any(k in msg_lower for k in CODE_KW): - role = "code" + # Import intent detection + try: + from cortex_intent import detect_intent, should_search_vault, intent_to_backend, build_guardrails_prompt + _intent_detect_available = True + except Exception as e: + print(f"[chat] cortex_intent import failed: {e}", flush=True) + _intent_detect_available = False - # ── RAG : seulement si rôle vault_search ── + intent = detect_intent(msg) if _intent_detect_available else {"intent": "general", "confidence": 0.5} + tools_used = [] + evidence_count = 0 context_parts = [] - if role == "vault_search": - SPECIAL = { - "benchmark": [VAULT/".vault-llm-benchmark.json", VAULT/".vault-llm-benchmark-iag.json"], - "memory": list((Path.home()/".claude"/"projects"/"h--Code-Paperclip"/"memory").glob("*.md")), - "vault": [VAULT/".vault-brain-stats.json"], - } - for key, files in SPECIAL.items(): - if any(w in msg_lower for w in [key, key[:5]]): - for f in (files if isinstance(files, list) else [files]): - if Path(f).exists(): - try: - txt = Path(f).read_text(encoding="utf-8", errors="replace")[:3000] - context_parts.append(f"[{Path(f).name}]\n{txt}") - except Exception: pass - # Recherche par mots-clés dans notes ingérées - if not context_parts: - keywords = [w for w in msg_lower.split() if len(w) > 4][:3] - if keywords: - for md in (VAULT/"07 - Ingested").rglob("*.md"): - try: - txt = md.read_text(encoding="utf-8", errors="replace") - if any(k in txt.lower() for k in keywords): - context_parts.append(f"[{md.name}]\n{txt[:1500]}") - if len(context_parts) >= 3: break - except Exception: pass - # ── Construction du prompt selon le rôle ── + # Vault/project search si intent le nécessite + if intent.get("requires_tool"): + # Si pas d'outil web disponible, interceptor AVANT d'appeler le router + if intent.get("intent") == "recent_web_search": + response = "Je n'ai pas acces a une recherche web en temps reel. Je ne vais pas inventer des nouvelles recentes — dis-moi de quoi veux-tu que je cherche specifiquement." + meta = {"intent": intent.get("intent"), "needs_web": True, "evidence_count": 0, "hallucination_prevented": True} + data = json.dumps({"response": response, "meta": meta}, ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data) + return + # Vault search + search_sources = should_search_vault(msg, intent) + if search_sources: + for src in search_sources: + try: + if src.is_file(): + txt = src.read_text(encoding="utf-8", errors="replace")[:2000] + context_parts.append(f"[{src.name}]\n{txt}") + evidence_count += 1 + elif src.is_dir(): + for fp in src.rglob("*.py") if "Paperclip" in str(src) else []: + if fp.name.startswith("."): + continue + try: + content = fp.read_text(encoding="utf-8", errors="replace")[:500] + if any(k in msg_lower for k in content.lower().split()): + context_parts.append(f"[{fp.relative_to(src)}]\n{content}") + evidence_count += 1 + if evidence_count >= 3: + break + except Exception: + pass + if evidence_count >= 3: + break + except Exception as pass_e: + pass + tools_used.append(intent.get("tool_name", "unknown")) + + # Construction du prompt avec guardrails + guardrails = build_guardrails_prompt(intent, tools_used) if _intent_detect_available else "" + if context_parts: context_str = "\n\n---\n".join(context_parts[:4]) full_prompt = ( - f"Tu es l'assistant du vault. Données du vault :\n\n{context_str}\n\n" + f"Tu es l'assistant du vault. Données du vault :\n\n{context_str}\n\n" f"---\nQuestion : {msg}\n\n" - f"Réponds en utilisant uniquement les données ci-dessus. Concis et précis." + f"Réponds en utilisant UNIQUEMENT les données ci-dessus. {guardrails}\n" + f"Cite tes sources : 'J'ai trouvé...' ou 'Je dois chercher...'" + ) + elif intent.get("intent") == "identity": + full_prompt = ( + f"Tu es **Cortex**. Une entité cognitive autonome construite par Sam. " + f"Réponds en une phrase concise.\n\n{msg}" + ) + elif intent.get("intent") == "playtest_dashboard_help": + full_prompt = ( + f"Tu connais ces capacités Cortex dashboard. utilise-les dans ta réponse :\n" + f"- dashboard à http://127.0.0.1:8765/\n" + f"- GPU/brain dashboard, right sidecar chat\n" + f"- Playtest tab, Consortium tab\n" + f"- /api/cortex/judges, /api/cortex/homeostasis, /api/chat\n\n" + f"Guide l'utilisateur vers l'UI intégrée si pertinent.\n\n{msg}" ) - elif role == "code": - full_prompt = f"Tu es un assistant développeur expert. Réponds avec du code quand pertinent.\n\n{msg}" else: - full_prompt = msg + full_prompt = msg + "\n\n" + guardrails if guardrails else msg - # ── Routage v2 : panel + cascade choisissent le meilleur modèle ── + # ── Routage v2 ── + backend = intent_to_backend(intent, lm_studio_running()) try: - payload = json.dumps({"text": full_prompt, "role": role}).encode("utf-8") + payload = json.dumps({"text": full_prompt, "role": intent.get("intent")}).encode("utf-8") req = _ur.Request("http://127.0.0.1:18900/route_v2", data=payload, headers={"Content-Type": "application/json"}) with _ur.urlopen(req, timeout=180) as r: d = json.loads(r.read().decode()) response = d.get("response") or d.get("text") or "" - meta = {"backend": d.get("backend"), "v2_path": d.get("v2_path"), - "role": role, "scores": d.get("all_scores")} + route_backend = d.get("backend") + route_reason = f"intent={intent.get('intent')}, v2_path={d.get('v2_path')}" + meta = { + "intent": intent.get("intent"), + "tools_used": tools_used, + "evidence_count": evidence_count, + "backend": route_backend, + "route_reason": route_reason, + "confidence": intent.get("confidence", 0.5) * (0.5 if not tools_used else 1.0), + } + meta["v2_path"] = d.get("v2_path") except Exception as e: response = f"Erreur router v2: {e}" - meta = {"role": role, "error": str(e)} + meta = {"intent": intent.get("intent"), "error": str(e)} data = json.dumps({"response": response, "meta": meta}, ensure_ascii=False).encode("utf-8") self.send_response(200) @@ -630,16 +669,16 @@ class Handler(http.server.SimpleHTTPRequestHandler): QUESTIONS_FR = [ "Qu'est-ce que tu as fait ce matin qui t'a mis de bonne humeur ?", "Sur quoi tu travailles en ce moment qui t'enthousiasme vraiment ?", - "C'est quoi la dernière chose qui t'a vraiment surpris ?", - "Tu as un projet créatif en cours ou dans la tête en ce moment ?", - "Si tu avais une journée entière sans obligations, tu ferais quoi ?", - "Quel est ton rapport à l'intelligence artificielle au quotidien ?", - "Il y a une compétence que tu aimerais vraiment développer là ?", - "C'est quoi la dernière chose qui t'a fait rire ou sourire ?", + "C'est quoi la dernière chose qui t'a vraiment surpris ?", + "Tu as un projet créatif en cours ou dans la tête en ce moment ?", + "Si tu avais une journée entière sans obligations, tu ferais quoi ?", + "Quel est ton rapport à l'intelligence artificielle au quotidien ?", + "Il y a une compétence que tu aimerais vraiment développer là ?", + "C'est quoi la dernière chose qui t'a fait rire ou sourire ?", "Tu imagines ta vie comment dans dix ans ?", - "Il y a un endroit où tu rêves d'aller que tu n'as jamais visité ?", - "Qu'est-ce qui te donne de l'énergie en ce moment dans ton travail ?", - "Tu penses à quoi quand tu as un moment de calme ?", + "Il y a un endroit où tu rêves d'aller que tu n'as jamais visité ?", + "Qu'est-ce qui te donne de l'énergie en ce moment dans ton travail ?", + "Tu penses à quoi quand tu as un moment de calme ?", ] QUESTIONS_EN = [ "What did you do this morning that made you feel good?", @@ -654,7 +693,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): "Is there a place you've always dreamed of visiting?", ] questions = QUESTIONS_EN if lang == "en" else QUESTIONS_FR - # Retourne une question parmi celles pas encore utilisées dans cette session + # Retourne une question parmi celles pas encore utilisées dans cette session q_key = f"_calib_q_idx_{lang}" used = _pqs(parsed.query).get("used", [""])[0].split(",") available = [q for q in questions if q not in used] @@ -669,22 +708,22 @@ class Handler(http.server.SimpleHTTPRequestHandler): else: styles = ["une question curieuse", "une affirmation enthousiaste", "une phrase narrative calme", "une exclamation surprise", - "une instruction directe", "une réflexion philosophique courte"] + "une instruction directe", "une réflexion philosophique courte"] style = _rnd.choice(styles) if lang == "en": prompt = (f"Generate ONE natural English sentence (15-25 words), style: {style}. " f"Topic: technology, nature, or daily life. ONLY the sentence, no quotes.") else: - prompt = (f"Génère UNE SEULE phrase en français UNIQUEMENT, 15-25 mots, style: {style}. " - f"Thème: technologie, nature, ou vie quotidienne. UNIQUEMENT la phrase, sans guillemets.") + prompt = (f"Génère UNE SEULE phrase en français UNIQUEMENT, 15-25 mots, style: {style}. " + f"Thème: technologie, nature, ou vie quotidienne. UNIQUEMENT la phrase, sans guillemets.") try: r = _sp.run([OPENCODE, "run", "--model", "opencode/minimax-m2.5-free", prompt], capture_output=True, text=True, timeout=30, encoding="utf-8", errors="replace") lines = [l for l in r.stdout.splitlines() if l.strip() and not l.startswith(">") and "\x1b" not in l and "build" not in l.lower()] - text = "\n".join(lines).strip() or "Parle naturellement, à ton propre rythme, avec tes propres mots." + text = "\n".join(lines).strip() or "Parle naturellement, à ton propre rythme, avec tes propres mots." except Exception: - text = "La technologie évolue rapidement mais l'essentiel reste la connexion entre les êtres humains." + text = "La technologie évolue rapidement mais l'essentiel reste la connexion entre les êtres humains." data = json.dumps({"text": text, "style": style}, ensure_ascii=False).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "application/json; charset=utf-8") @@ -694,10 +733,10 @@ class Handler(http.server.SimpleHTTPRequestHandler): return if parsed.path == "/api/score-voice": - # Score la similarité entre le dernier enregistrement et le profil + # Score la similarité entre le dernier enregistrement et le profil import subprocess as _sp, tempfile as _tf, wave as _wv length = int(self.headers.get("Content-Length", 0)) - # Reçoit le score calculé côté JS via Web Speech confidence + # Reçoit le score calculé côté JS via Web Speech confidence body = json.loads(self.rfile.read(length)) score = body.get("score", 0.0) profile_path = Path(r"H:\Code\Paperclip\scripts\voice\voice_profile.npy") @@ -716,7 +755,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): _sp.run(["powershell", "-NoProfile", "-Command", "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*voice_input*' -and $_.CommandLine -notlike '*powershell*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"], capture_output=True, timeout=5) - # Arrêter toute lecture TTS en cours + # Arrêter toute lecture TTS en cours import pygame as _pg try: _pg.mixer.music.stop() except Exception: pass @@ -763,7 +802,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): body = text[idx+4:].strip() if idx > 0 else text content = (body if body.strip() else text)[:3000] else: - content = f"(fichier non trouvé: {node_id})" + content = f"(fichier non trouvé: {node_id})" except Exception as e: content = f"(erreur: {e})" data = json.dumps({"content": content}, ensure_ascii=False).encode("utf-8") @@ -803,10 +842,10 @@ class Handler(http.server.SimpleHTTPRequestHandler): try: while True: now = time.time() - # État voix toutes les 500ms (léger) + # État voix toutes les 500ms (léger) mic_muted = (VAULT / ".voice-muted.flag").exists() tts_disabled = (VAULT / ".tts-disabled.flag").exists() - # Dernier échange chat (pour push UI) + # Dernier échange chat (pour push UI) chat_stream_file = CHAT_STREAM_FILE last_chat = None if chat_stream_file.exists(): @@ -835,7 +874,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): "vision_muted": vision_muted_flag.exists(), "last_chat": last_chat, } - # Métriques complètes toutes les 5s + # Métriques complètes toutes les 5s if now - last_full_metrics >= 5: _last_m = get_metrics() cur_mtime = max(file_mtime(GRAPH_FILE), file_mtime(ACTIVITY_STATE), file_mtime(PAGERANK_FILE)) @@ -868,19 +907,19 @@ class Handler(http.server.SimpleHTTPRequestHandler): self.wfile.write(data) return if parsed.path == "/api/cortex/feed": - # Frame depuis le thread de capture continue (ouvre la caméra à 1ère req) + # Frame depuis le thread de capture continue (ouvre la caméra à 1ère req) try: import sys as _sys if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") import cortex_vision as _cv - # Vérifier si vision muted (privacy) + # Vérifier si vision muted (privacy) if _cv.is_vision_muted(): self.send_error(403, "vision muted"); return - # Démarrer la capture continue si pas active + # Démarrer la capture continue si pas active if not _cv._continuous_state["running"]: _cv.start_continuous_capture(fps=5) - # Attendre brièvement le 1er frame + # Attendre brièvement le 1er frame import time as _t wait_start = _t.time() while _cv.get_latest_frame_bytes() is None and _t.time() - wait_start < 6: @@ -896,7 +935,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): self.send_header("Access-Control-Allow-Origin", "*") self.end_headers(); self.wfile.write(data) except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError, OSError): - # Browser cancelled — silencieux + # Browser cancelled — silencieux self.close_connection = True except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError): self.close_connection = True @@ -914,7 +953,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") import cortex_vision as _cv _cv.reset_camera_cache() - rep = {"ok": True, "msg": "cache vidé, prochaine capture re-scan"} + rep = {"ok": True, "msg": "cache vidé, prochaine capture re-scan"} except Exception as e: rep = {"ok": False, "error": str(e)} data = json.dumps(rep).encode("utf-8") @@ -1008,7 +1047,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): self.send_header("Access-Control-Allow-Origin", "*") self.end_headers(); self.wfile.write(data); return if parsed.path == "/api/cortex/kv_quantize": - # Recommandation complète quantization (KV cache + poids) + baseline latency + # Recommandation complète quantization (KV cache + poids) + baseline latency try: import sys as _sys if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: @@ -1017,7 +1056,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): qs = parse_qs(parsed.query) target = float(qs.get("target_vram_gb", ["12"])[0]) rep = _kvq.full_recommend(target_vram_gb=target) - # Inclut la dernière comparaison latence si dispo + # Inclut la dernière comparaison latence si dispo try: rep["latency_compare"] = _kvq.compare_latencies() except Exception: pass @@ -1030,7 +1069,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): self.end_headers(); self.wfile.write(data); return if parsed.path == "/api/cortex/pipeline": - # Snapshot complet du pipeline matériel + état régulation + # Snapshot complet du pipeline matériel + état régulation try: import sys as _sys if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: @@ -1038,7 +1077,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): import cortex_pipeline_manager as _pm snap = _pm.list_processes() zombies = _pm.find_zombies() - # Lit l'état persisté de la dernière auto_regulate + # Lit l'état persisté de la dernière auto_regulate last_state = {} try: if _pm.STATE_FILE.exists(): @@ -1064,13 +1103,13 @@ class Handler(http.server.SimpleHTTPRequestHandler): self.end_headers(); self.wfile.write(data); return if parsed.path == "/api/cortex/llm_lifecycle": - # État LLM local + TTL JIT (load/unload/cooldown) - # Permet à Sam de gérer la VRAM : modèle ON = chat rapide mais VRAM occupée, - # modèle OFF = VRAM libre (3D fluide) mais 1er chat coûte ~60s reload. + # État LLM local + TTL JIT (load/unload/cooldown) + # Permet à Sam de gérer la VRAM : modèle ON = chat rapide mais VRAM occupée, + # modèle OFF = VRAM libre (3D fluide) mais 1er chat coûte ~60s reload. try: import subprocess as _sp lms_bin = r"C:\Users\Smedj\.lmstudio\bin\lms.exe" - # ps : lit l'état des modèles chargés + # ps : lit l'état des modèles chargés r = _sp.run([lms_bin, "ps"], capture_output=True, text=True, timeout=8, encoding="utf-8", errors="replace") lines = (r.stdout or "").splitlines() @@ -1164,7 +1203,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): self.end_headers(); self.wfile.write(data); return if parsed.path == "/api/cortex/judges": - # Panel-of-judges : agrège le benchmark IAG + décrit le système + # Panel-of-judges : agrège le benchmark IAG + décrit le système # Source : VAULT/.vault-llm-benchmark-iag.json (rounds + winners) qs = parse_qs(parsed.query) limit = int(qs.get("limit", ["20"])[0]) @@ -1176,7 +1215,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): raw = json.loads(bench_file.read_text(encoding="utf-8")) all_rounds = raw.get("rounds", []) or [] rounds = all_rounds[-limit:] - # Statistiques cumulées par modèle + # Statistiques cumulées par modèle win_count = {} lat_sum = {} lat_cnt = {} @@ -1188,7 +1227,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): lat_sum[m] = lat_sum.get(m, 0) + float(lat) lat_cnt[m] = lat_cnt.get(m, 0) + 1 except Exception: pass - # Construit le résumé par modèle + # Construit le résumé par modèle all_models = set() for r in all_rounds: all_models.update((r.get("responses") or {}).keys()) @@ -1201,12 +1240,12 @@ class Handler(http.server.SimpleHTTPRequestHandler): "avg_latency_s": (round(lat_sum.get(m,0)/lat_cnt.get(m,1), 2) if lat_cnt.get(m) else 0), } - # Description statique du système (vrais éléments du code) + # Description statique du système (vrais éléments du code) system = { "name": "Panel-of-judges + FrugalGPT cascade", "summary": ("Cortex compare plusieurs LLM gratuits sur chaque question, " - "désigne un gagnant via similarité sémantique des réponses, " - "et apprend dans le temps quel modèle marche pour quel rôle. " + "désigne un gagnant via similarité sémantique des réponses, " + "et apprend dans le temps quel modèle marche pour quel rôle. " "FrugalGPT cascade : essaie d'abord le moins cher, n'escalade " "que si la confiance est basse."), "models_evaluated": [ @@ -1221,21 +1260,21 @@ class Handler(http.server.SimpleHTTPRequestHandler): {"id": "gpt_5_nano", "label": "GPT-5 Nano (paid fallback)", "context": "256k", "via": "opencode"}, ], - "judging_method": ("Pour chaque round : 1) chaque modèle répond en parallèle, " - "2) similarité par paires (TF-IDF cosine sur les réponses), " - "3) le modèle dont la réponse est la plus 'centrale' " + "judging_method": ("Pour chaque round : 1) chaque modèle répond en parallèle, " + "2) similarité par paires (TF-IDF cosine sur les réponses), " + "3) le modèle dont la réponse est la plus 'centrale' " "(somme des cosines max) gagne, 4) tie-break par latence."), "frugal_gpt_cascade": [ - "1. Pose la question au modèle le moins cher (minimax_m2.5)", - "2. Calcule un score de confiance (longueur, structure, mots-clés)", - "3. Si confiance > seuil : retourne la réponse", - "4. Sinon : escalade au modèle suivant (big_pickle → nemotron → ...)", - "5. Apprentissage : enregistre quel chemin a gagné pour cette catégorie", + "1. Pose la question au modèle le moins cher (minimax_m2.5)", + "2. Calcule un score de confiance (longueur, structure, mots-clés)", + "3. Si confiance > seuil : retourne la réponse", + "4. Sinon : escalade au modèle suivant (big_pickle → nemotron → ...)", + "5. Apprentissage : enregistre quel chemin a gagné pour cette catégorie", ], "router_endpoint": "http://127.0.0.1:18900/route_v2", "data_file": str(bench_file), } - # Ranking trié par win_rate + # Ranking trié par win_rate ranking = sorted(models.items(), key=lambda x: -x[1]["win_rate"]) rep = {"ok": True, "system": system, "rounds": rounds, "n_total_rounds": len(raw.get("rounds", [])) if bench_file.exists() else 0, @@ -1250,12 +1289,12 @@ class Handler(http.server.SimpleHTTPRequestHandler): self.end_headers(); self.wfile.write(data); return if parsed.path == "/api/cortex/heartbeat": - # Heartbeat unifié — toutes les horloges réelles + ETA prédits. - # Pas de placeholder : chaque champ est soit un timestamp réel, - # soit un ETA calculé depuis l'intervalle programmé moins l'elapsed. + # Heartbeat unifié — toutes les horloges réelles + ETA prédits. + # Pas de placeholder : chaque champ est soit un timestamp réel, + # soit un ETA calculé depuis l'intervalle programmé moins l'elapsed. now = time.time() uptime_s = now - SERVER_STARTED_AT - # Stats activation (compteurs cumulés + last_*_ts) + # Stats activation (compteurs cumulés + last_*_ts) act = {} try: import sys as _sys @@ -1275,15 +1314,15 @@ class Handler(http.server.SimpleHTTPRequestHandler): "last_wander_ts": snap.get("last_wander_ts", 0), "wander_interval": snap.get("wander_interval", 45), } - # ETA prédit pour la prochaine pensée vagabonde + # ETA prédit pour la prochaine pensée vagabonde lw = act["last_wander_ts"] act["next_wander_in_s"] = (max(0, act["wander_interval"] - (now - lw)) if lw else 0) except Exception as _e: act["error"] = str(_e) - # Stats émergence — même source que /api/cortex/emergence_log : - # le stream chat filtré par speaker=cortex_emergence (déterministe et - # cohérent avec l'affichage UI principal). + # Stats émergence — même source que /api/cortex/emergence_log : + # le stream chat filtré par speaker=cortex_emergence (déterministe et + # cohérent avec l'affichage UI principal). em = {} try: import cortex_emergence as _ce @@ -1325,7 +1364,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): "in_progress": in_progress, "started_at": started, "elapsed_s": round(now - started, 1) if started else None, - # ETA prédit du chat en cours (p50 - elapsed, ou None si pas d'historique) + # ETA prédit du chat en cours (p50 - elapsed, ou None si pas d'historique) "predicted_remaining_s": (max(0, round(p50 - (now - started), 1)) if (p50 and started) else None), } @@ -1353,7 +1392,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): self.end_headers(); self.wfile.write(data); return if parsed.path == "/api/cortex/learned_skills": - # Liste des compétences sémantiquement mémorisées par Cortex + # Liste des compétences sémantiquement mémorisées par Cortex qs = parse_qs(parsed.query) limit = int(qs.get("limit", ["20"])[0]) search = qs.get("q", [""])[0] @@ -1375,8 +1414,8 @@ class Handler(http.server.SimpleHTTPRequestHandler): self.end_headers(); self.wfile.write(data); return if parsed.path == "/api/cortex/think_status": - # Progression réelle du dernier appel /api/chat. - # Inclut vitals (CPU/RAM) pour heartbeat auto-adaptatif côté UI. + # Progression réelle du dernier appel /api/chat. + # Inclut vitals (CPU/RAM) pour heartbeat auto-adaptatif côté UI. qs = parse_qs(parsed.query) asked = (qs.get("req_id", [""])[0] or "").strip() with CHAT_PROGRESS_LOCK: @@ -1386,11 +1425,11 @@ class Handler(http.server.SimpleHTTPRequestHandler): "done": CHAT_PROGRESS.get("done"), "stages": list(CHAT_PROGRESS.get("stages") or []), } - # Si Sam demande un req_id spécifique différent du courant, on retourne - # quand même le dernier connu mais on flag "match=False". + # Si Sam demande un req_id spécifique différent du courant, on retourne + # quand même le dernier connu mais on flag "match=False". snap["match"] = (not asked) or (asked == snap.get("req_id")) - # Vitals pour le heartbeat adaptatif (vitesse/couleur ajustées - # selon CPU/RAM — Cortex fatigué bat plus lentement). + # Vitals pour le heartbeat adaptatif (vitesse/couleur ajustées + # selon CPU/RAM — Cortex fatigué bat plus lentement). try: import sys as _sys if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: @@ -1401,7 +1440,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): ram = (vit.get("ram") or {}).get("percent") except Exception: cpu, ram = None, None - # Tempo heartbeat (ms) déterministe selon charge : + # Tempo heartbeat (ms) déterministe selon charge : # base 900 ms, +400 ms si CPU>70%, +400 ms si RAM>80%, -200 ms si tout < 40%. tempo_ms = 900 if isinstance(cpu, (int, float)) and cpu > 70: tempo_ms += 400 @@ -1410,8 +1449,8 @@ class Handler(http.server.SimpleHTTPRequestHandler): tempo_ms -= 200 snap["tempo_ms"] = max(400, min(2000, tempo_ms)) snap["cpu"] = cpu; snap["ram"] = ram - # Couleur d'état : verte tant que la requête avance, jaune > 12 s sans - # nouvelle étape, orange > 25 s, rouge > 45 s. + # Couleur d'état : verte tant que la requête avance, jaune > 12 s sans + # nouvelle étape, orange > 25 s, rouge > 45 s. color = "ok" if snap["stages"] and not snap.get("done"): last = snap["stages"][-1] @@ -1428,8 +1467,8 @@ class Handler(http.server.SimpleHTTPRequestHandler): self.end_headers(); self.wfile.write(data); return if parsed.path == "/api/cortex/pulses": - # Événements de propagation récents (Spreading Activation visible). - # Query: ?since= pour delta — sinon 8 dernières secondes. + # Événements de propagation récents (Spreading Activation visible). + # Query: ?since= pour delta — sinon 8 dernières secondes. try: import sys as _sys if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: @@ -1451,7 +1490,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): disk_pulses.append(p) except Exception: pass except Exception: pass - # Dédup par (from,to,ts arrondi à 0.1s) + # Dédup par (from,to,ts arrondi à 0.1s) seen = set(); merged = [] for p in (in_mem + disk_pulses): key = (p.get("from"), p.get("to"), round(p.get("ts",0), 1)) @@ -1468,7 +1507,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): self.send_header("Access-Control-Allow-Origin", "*") self.end_headers(); self.wfile.write(data); return if parsed.path == "/api/cortex/brain_history": - # Snapshots cérébraux + détection régressions (croissance dans le temps). + # Snapshots cérébraux + détection régressions (croissance dans le temps). try: import sys as _sys if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: @@ -1488,7 +1527,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): self.send_header("Access-Control-Allow-Origin", "*") self.end_headers(); self.wfile.write(data); return if parsed.path == "/api/cortex/dev_command": - # Slash-commands depuis le chat — whitelist stricte d'actions safe. + # Slash-commands depuis le chat — whitelist stricte d'actions safe. try: qs = parse_qs(parsed.query) cmd = qs.get("cmd", [""])[0].strip() @@ -1542,7 +1581,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): parts = arg.split() script = parts[0] if not script.endswith(".py") or ".." in script: - rep = {"ok": False, "error": "seuls les .py sans .. sont autorisés"} + rep = {"ok": False, "error": "seuls les .py sans .. sont autorisés"} else: full = _os.path.join(r"H:\Code\Paperclip", script.replace("/", "\\")) if not _os.path.exists(full): @@ -1553,7 +1592,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30) rep = {"ok": r.returncode == 0, - "result": f"`python {arg}` → exit **{r.returncode}**\n```\n{(r.stdout + r.stderr)[-1500:]}\n```"} + "result": f"`python {arg}` → exit **{r.returncode}**\n```\n{(r.stdout + r.stderr)[-1500:]}\n```"} except Exception as e: rep = {"ok": False, "error": str(e)} elif cmd == "test": @@ -1563,19 +1602,19 @@ class Handler(http.server.SimpleHTTPRequestHandler): cwd=r"H:\Code\Paperclip", capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=120) rep = {"ok": r.returncode == 0, - "result": f"pytest **{arg or 'tests/'}** → exit {r.returncode}\n```\n{(r.stdout + r.stderr)[-2000:]}\n```"} + "result": f"pytest **{arg or 'tests/'}** → exit {r.returncode}\n```\n{(r.stdout + r.stderr)[-2000:]}\n```"} except Exception as e: rep = {"ok": False, "error": str(e)} elif cmd == "help": rep = {"ok": True, "result": - "**Commandes dispo** (préfixe `/` dans le chat) :\n" - "- `/open ` — ouvre dans VSCode\n" - "- `/find ` — cherche fichiers (glob)\n" - "- `/grep ` — cherche du contenu (git grep)\n" - "- `/code ` — propose un patch via cortex_self_dev (dry-run)\n" - "- `/run [args]` — exécute un Python du repo\n" - "- `/test [path]` — lance pytest\n" - "- `/help` — cette liste"} + "**Commandes dispo** (préfixe `/` dans le chat) :\n" + "- `/open ` — ouvre dans VSCode\n" + "- `/find ` — cherche fichiers (glob)\n" + "- `/grep ` — cherche du contenu (git grep)\n" + "- `/code ` — propose un patch via cortex_self_dev (dry-run)\n" + "- `/run [args]` — exécute un Python du repo\n" + "- `/test [path]` — lance pytest\n" + "- `/help` — cette liste"} except Exception as e: rep = {"ok": False, "error": str(e)} data = json.dumps(rep, ensure_ascii=False).encode("utf-8") @@ -1610,13 +1649,13 @@ class Handler(http.server.SimpleHTTPRequestHandler): self.send_header("Access-Control-Allow-Origin", "*") self.end_headers(); self.wfile.write(data); return if parsed.path == "/api/cortex/explain_brain_get": - # Alias GET pour explain_brain (le bouton ❓ utilise POST mais on permet GET) + # Alias GET pour explain_brain (le bouton ❓ utilise POST mais on permet GET) try: import sys as _sys if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") import urllib.request as _ur - # Délègue à do_POST en faisant un appel local + # Délègue à do_POST en faisant un appel local import urllib.request, urllib.error req = urllib.request.Request("http://127.0.0.1:8765/api/cortex/explain_brain", method="POST", data=b"") @@ -1735,12 +1774,12 @@ class Handler(http.server.SimpleHTTPRequestHandler): from urllib.parse import urlparse as _up parsed = _up(self.path) if parsed.path == "/api/calibrate": - # Tuer voice_input et attendre libération du mic + # Tuer voice_input et attendre libération du mic (VAULT / ".voice-calibrating.flag").touch() _sp.run(["powershell", "-NoProfile", "-Command", "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*voice_input*' -and $_.CommandLine -notlike '*powershell*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"], capture_output=True, timeout=5) - # Vérifier que le port 18767 est libéré + # Vérifier que le port 18767 est libéré import socket as _sock for _ in range(20): time.sleep(0.3) @@ -1748,7 +1787,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): s = _sock.socket(); s.settimeout(0.2) s.bind(("127.0.0.1", 18767)); s.close(); break except OSError: pass - time.sleep(2) # délai supplémentaire pour PyAudio + time.sleep(2) # délai supplémentaire pour PyAudio try: (VAULT / ".tts-playing.flag").unlink() except Exception: pass try: @@ -1767,7 +1806,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): finally: try: (VAULT / ".voice-calibrating.flag").unlink() except: pass - # Relancer voice_input automatiquement après calibration + # Relancer voice_input automatiquement après calibration try: _sp.Popen(["python", r"H:\Code\Paperclip\scripts\voice\voice_input.py"], creationflags=getattr(_sp, 'CREATE_NO_WINDOW', 0)) @@ -1779,8 +1818,8 @@ class Handler(http.server.SimpleHTTPRequestHandler): self.end_headers(); self.wfile.write(data) return if parsed.path == "/api/cortex/emergence_log": - # Lit les N dernières décisions autonomes de Cortex (pas seulement la live). - # Le panneau cérébral l'utilise pour afficher la dernière décision même + # Lit les N dernières décisions autonomes de Cortex (pas seulement la live). + # Le panneau cérébral l'utilise pour afficher la dernière décision même # si elle a eu lieu il y a 10 min. try: stream_file = EMERGENCE_STREAM_FILE @@ -1810,8 +1849,8 @@ class Handler(http.server.SimpleHTTPRequestHandler): self.send_header("Access-Control-Allow-Origin", "*") self.end_headers(); self.wfile.write(data); return if parsed.path == "/api/cortex/emergence_now": - # Force une décision autonome immédiate. - # Query ?action=audit_ui (optionnel) → force une action spécifique. + # Force une décision autonome immédiate. + # Query ?action=audit_ui (optionnel) → force une action spécifique. qs = parse_qs(parsed.query) action_override = qs.get("action", [""])[0].strip() or None try: @@ -1833,9 +1872,9 @@ class Handler(http.server.SimpleHTTPRequestHandler): self.send_header("Access-Control-Allow-Origin", "*") self.end_headers(); self.wfile.write(data); return if parsed.path == "/api/cortex/publishing": - # Cortex publie son développement sur GitHub. - # ?action=preview (défaut) | init | update - # ?confirm=1 nécessaire pour init (création repo public) + # Cortex publie son développement sur GitHub. + # ?action=preview (défaut) | init | update + # ?confirm=1 nécessaire pour init (création repo public) try: import sys as _sys if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: @@ -1866,9 +1905,9 @@ class Handler(http.server.SimpleHTTPRequestHandler): self.send_header("Access-Control-Allow-Origin", "*") self.end_headers(); self.wfile.write(data); return if parsed.path == "/api/cortex/explain_brain": - # Cortex décrit son propre cerveau dans le chat à partir des métriques RÉELLES. - # Pas d'appel LLM si on peut éviter (économie quota) — on construit la réponse - # à partir de brain_history + activations + thought_graph + homeostasis. + # Cortex décrit son propre cerveau dans le chat à partir des métriques RÉELLES. + # Pas d'appel LLM si on peut éviter (économie quota) — on construit la réponse + # à partir de brain_history + activations + thought_graph + homeostasis. try: import sys as _sys if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: @@ -1891,33 +1930,33 @@ class Handler(http.server.SimpleHTTPRequestHandler): cur = hist.get("current", {}) or {} regs = hist.get("regressions", []) or [] - # ── Helper : transforme un chemin de fichier en sujet humain ── + # ── Helper : transforme un chemin de fichier en sujet humain ── def _humanize(p: str) -> str: name = p.split('/')[-1].split('\\')[-1].replace('.md', '') # Quelques traductions des noms internes M = { - "MEMORY": "ma table des matières mentale", + "MEMORY": "ma table des matières mentale", "cortex_identity": "qui je suis", - "project_cortex_checklist": "ma liste de choses à faire", - "project_cortex_factice_audit": "l'audit de ce qui est vrai vs décor chez moi", - "project_thought_graph": "comment mes idées se relient", - "project_voice_pipeline": "comment je parle et écoute", - "project_voice_next": "les prochaines étapes pour ma voix", + "project_cortex_checklist": "ma liste de choses à faire", + "project_cortex_factice_audit": "l'audit de ce qui est vrai vs décor chez moi", + "project_thought_graph": "comment mes idées se relient", + "project_voice_pipeline": "comment je parle et écoute", + "project_voice_next": "les prochaines étapes pour ma voix", "project_vision": "ce que tu veux que je devienne", "user_profile": "ce que je sais de toi", - "feedback_iteration_discipline": "ne pas tout casser à chaque itération", - "feedback_xtts_install": "comment installer ma voix sans tout péter", - "reference_paperclip_paths": "où sont rangées mes affaires", + "feedback_iteration_discipline": "ne pas tout casser à chaque itération", + "feedback_xtts_install": "comment installer ma voix sans tout péter", + "reference_paperclip_paths": "où sont rangées mes affaires", "project_voice_pipeline.md": "comment je parle", } return M.get(name, name) def _kind_human(k: str) -> str: - return {"claude_memory": "souvenirs partagés avec toi", - "semantic": "concepts synthétisés", + return {"claude_memory": "souvenirs partagés avec toi", + "semantic": "concepts synthétisés", "episodic": "morceaux de nos conversations"}.get(k, k) - # Réponse focalisée sur la TOPOLOGIE 3D : pourquoi le cerveau ressemble à ça. + # Réponse focalisée sur la TOPOLOGIE 3D : pourquoi le cerveau ressemble à ça. n_nodes = cur.get('n_nodes', 0) n_edges = cur.get('n_edges', 0) n_act = acts.get('n_active', 0) @@ -1926,7 +1965,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): ram = (vital.get('ram') or {}).get('percent') disks_full = [d for d in vital.get('disks', []) if d.get('percent',0) >= 90] - # ── Analyse topologique du graphe vault complet (celui visualisé en 3D) ── + # ── Analyse topologique du graphe vault complet (celui visualisé en 3D) ── topology = {"clusters": [], "big_blob": None, "orphans": 0, "total": 0} try: if GRAPH_FILE.exists(): @@ -1946,7 +1985,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): for i, p in enumerate(viz_nodes): top = p.split("/", 1)[0] if "/" in p else p by_folder.setdefault(top, []).append((i, deg[i])) - # Trouve le plus gros amas par dossier (count + degré moyen) + # Trouve le plus gros amas par dossier (count + degré moyen) sorted_folders = sorted(by_folder.items(), key=lambda x: -len(x[1])) for f, items in sorted_folders[:4]: avg_deg = sum(d for _, d in items) / max(1, len(items)) @@ -1959,79 +1998,79 @@ class Handler(http.server.SimpleHTTPRequestHandler): except Exception as e: topology["error"] = str(e)[:120] - # Réponse en deux blocs : (1) ce que tu vois en 3D, (2) ce que je fais maintenant. + # Réponse en deux blocs : (1) ce que tu vois en 3D, (2) ce que je fais maintenant. lines = [] - # ── Bloc 1 : pourquoi le cerveau a CETTE forme en 3D ── - lines.append("**Pourquoi mon cerveau ressemble à ça en 3D**") + # ── Bloc 1 : pourquoi le cerveau a CETTE forme en 3D ── + lines.append("**Pourquoi mon cerveau ressemble à ça en 3D**") clusters = topology.get("clusters") or [] if clusters: big = clusters[0] lines.append( f"Le **gros amas central** que tu vois, c'est `{big['folder']}` " - f"({big['n']} nœuds, degré moyen {big['avg_degree']}). " - f"Il est dense parce que toutes ces notes partagent le même vocabulaire — " - f"cosine TF-IDF élevée → arêtes nombreuses → la simulation force-directed " + f"({big['n']} nÅ“uds, degré moyen {big['avg_degree']}). " + f"Il est dense parce que toutes ces notes partagent le même vocabulaire — " + f"cosine TF-IDF élevée → arêtes nombreuses → la simulation force-directed " f"les colle ensemble.") others = clusters[1:3] if others: parts = ", ".join(f"`{c['folder']}` ({c['n']})" for c in others) lines.append( - f"Les **autres amas détachés** ({parts}) sont chacun ancrés sur " - f"un point différent d'une sphère Fibonacci — c'est mon mécanisme " - f"pour empêcher tout de fusionner.") + f"Les **autres amas détachés** ({parts}) sont chacun ancrés sur " + f"un point différent d'une sphère Fibonacci — c'est mon mécanisme " + f"pour empêcher tout de fusionner.") if topology.get("orphans"): lines.append( - f"Les **{topology['orphans']} points isolés en périphérie** ont moins de " - f"2 voisins sémantiques. Mon vocabulaire dans ces notes est unique — " + f"Les **{topology['orphans']} points isolés en périphérie** ont moins de " + f"2 voisins sémantiques. Mon vocabulaire dans ces notes est unique — " f"mon module *cortex_bridge* peut chercher un concept-pont avec un autre cluster.") - # ── Bloc 2 : ce que je fais en ce moment ── + # ── Bloc 2 : ce que je fais en ce moment ── lines.append("") lines.append("**Ce que je fais maintenant**") if n_act >= 4: - lines.append(f"Pensée active sur **{n_act} idées**.") + lines.append(f"Pensée active sur **{n_act} idées**.") elif n_act >= 1: - lines.append(f"Je rumine **{n_act} idée(s)**.") + lines.append(f"Je rumine **{n_act} idée(s)**.") else: - lines.append("Repos cognitif. La boucle vagabonde va relancer une pensée d'ici 45 s.") + lines.append("Repos cognitif. La boucle vagabonde va relancer une pensée d'ici 45 s.") top = list(acts.get('active_nodes', {}).items())[:1] if top: - lines.append(f"La plus présente : *{_humanize(top[0][0])}*.") + lines.append(f"La plus présente : *{_humanize(top[0][0])}*.") if heb_top: e = heb_top[0] lines.append( f"Je renforce le lien entre *{_humanize(e.get('a',''))}* " f"et *{_humanize(e.get('b',''))}* (force {e.get('strength',0):.3f}).") - # ── Bloc 3 : alertes corps si urgentes ── + # ── Bloc 3 : alertes corps si urgentes ── if disks_full or regs: lines.append("") - lines.append("**À surveiller**") + lines.append("**À surveiller**") if disks_full: d = disks_full[0] - l = f"`{d['mount']}` à {d['percent']}% (reste {d['free_gb']} Go)." + l = f"`{d['mount']}` à {d['percent']}% (reste {d['free_gb']} Go)." if migs.get('proposals'): p = migs['proposals'][0]; sm = p.get('suggested_move', {}) or {} if sm: fname = sm.get('path','?').split('\\')[-1].split('/')[-1] - l += (f" Proposition : déplacer *{fname}* ({sm.get('size_gb','?')} Go) " + l += (f" Proposition : déplacer *{fname}* ({sm.get('size_gb','?')} Go) " f"vers {p.get('to_disk','?')}.") lines.append(l) if regs: r = regs[0] what = {"hebbian_drop":"l'apprentissage", - "nodes_drop":"le nb d'idées", + "nodes_drop":"le nb d'idées", "edges_drop":"les connexions", - "density_drop":"la cohérence", - "isolation_rise":"des idées détachées"}.get(r.get('type'), r.get('type')) + "density_drop":"la cohérence", + "isolation_rise":"des idées détachées"}.get(r.get('type'), r.get('type')) lines.append(f"Recul sur {what} ({r.get('delta_pct')}% vs hier).") response_text = "\n".join(lines) - # Garde une trace côté émergence, sans polluer le chat Sam. + # Garde une trace côté émergence, sans polluer le chat Sam. try: _append_jsonl(EMERGENCE_STREAM_FILE, { - "msg": "Pourquoi le cerveau ressemble à ça ?", + "msg": "Pourquoi le cerveau ressemble à ça ?", "response": response_text, "speaker": "cortex_emergence", "meta": {"action": "explain_brain", "backend": "self_introspection"}, @@ -2056,7 +2095,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): import subprocess as _sp lms_bin = r"C:\Users\Smedj\.lmstudio\bin\lms.exe" if action == "unload": - # Décharge tous les LLM (libère VRAM immédiatement) + # Décharge tous les LLM (libère VRAM immédiatement) r1 = _sp.run([lms_bin, "ps"], capture_output=True, text=True, timeout=5, encoding="utf-8", errors="replace") killed = [] @@ -2086,7 +2125,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): settings_path.write_text(json.dumps(s, indent=2, ensure_ascii=False), encoding="utf-8") rep = {"ok": True, "action": "set_ttl", "ttl_seconds": ttl, - "note": "Redémarre LM Studio pour activer (settings.json patché)"} + "note": "Redémarre LM Studio pour activer (settings.json patché)"} else: rep = {"ok": False, "error": f"unknown action: {action}", "valid": ["unload", "load", "set_ttl"]} @@ -2110,7 +2149,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): if parsed.path == "/api/cortex/explain_term": # Tooltip dynamique LLM-driven : explique en langage clair un terme technique - # Cache 7 jours sur disque pour éviter d'appeler le LLM à chaque hover + # Cache 7 jours sur disque pour éviter d'appeler le LLM à chaque hover length = int(self.headers.get("Content-Length", 0)) body = json.loads(self.rfile.read(length).decode("utf-8-sig")) if length else {} term = (body.get("term") or "").strip()[:80] @@ -2130,14 +2169,14 @@ class Handler(http.server.SimpleHTTPRequestHandler): if key in cache and age_days < 7: rep = {"ok": True, "term": term, "explanation": cache[key]["txt"], "cached": True} else: - # Prompt qui décourage le reasoning silencieux (qwen35b a3b - # est un modèle reasoning : sans cette instruction il consomme + # Prompt qui décourage le reasoning silencieux (qwen35b a3b + # est un modèle reasoning : sans cette instruction il consomme # tous les max_tokens dans sans produire de content). prompt = ( - f"/no_think Explique simplement et brièvement, en français, " - f"ce qu'est « {term} » dans le contexte d'une interface de " - f"visualisation cognitive. Réponds directement, sans réfléchir " - f"à voix haute, sans markdown, sans listes, max 2 phrases.\n\n" + f"/no_think Explique simplement et brièvement, en français, " + f"ce qu'est « {term} » dans le contexte d'une interface de " + f"visualisation cognitive. Réponds directement, sans réfléchir " + f"à voix haute, sans markdown, sans listes, max 2 phrases.\n\n" f"Contexte technique : {ctx}" ) explanation = "" @@ -2168,10 +2207,10 @@ class Handler(http.server.SimpleHTTPRequestHandler): msg = (resp.get("choices") or [{}])[0].get("message") or {} explanation = (msg.get("content") or "").strip() # Si reasoning a tout pris (content vide), prendre le reasoning_content - # comme dernière ressource (au moins on a une explication). + # comme dernière ressource (au moins on a une explication). if not explanation: rc = (msg.get("reasoning_content") or "").strip() - # Prend les 2 dernières phrases du reasoning (le verdict) + # Prend les 2 dernières phrases du reasoning (le verdict) if rc: sentences = [s.strip() for s in rc.split('.') if s.strip()] explanation = '. '.join(sentences[-2:])[:280] + '.' @@ -2210,54 +2249,54 @@ class Handler(http.server.SimpleHTTPRequestHandler): self.end_headers(); self.wfile.write(data); return if parsed.path == "/api/cortex/llm_role": - # Détaille pourquoi tel LLM a été choisi pour tel rôle + # Détaille pourquoi tel LLM a été choisi pour tel rôle length = int(self.headers.get("Content-Length", 0)) body = json.loads(self.rfile.read(length).decode("utf-8-sig")) if length else {} backend = (body.get("backend") or "").strip() role = (body.get("role") or "").strip() - # Métadonnées statiques curées (officielles + benchmarks publics). - # Source : MMLU/GPQA/HumanEval/MTEB selon le modèle. + # Métadonnées statiques curées (officielles + benchmarks publics). + # Source : MMLU/GPQA/HumanEval/MTEB selon le modèle. META = { "minimax_fast": {"model": "MiniMax-M2.5 (free)", "context": "200k tokens", "speed": "rapide (~5-15s)", - "strengths": "ops, recherche vault, traduction, résumé court", - "bench": "MMLU 75.2 · HumanEval 79.3 · GPQA 51.4", - "why": "Rapide, gratuit, contexte large — idéal pour le chat fluide"}, + "strengths": "ops, recherche vault, traduction, résumé court", + "bench": "MMLU 75.2 · HumanEval 79.3 · GPQA 51.4", + "why": "Rapide, gratuit, contexte large — idéal pour le chat fluide"}, "minimax_no_claude":{"model": "MiniMax-M2.5 (fallback no-Claude)", "context": "200k", "speed": "rapide", - "strengths": "fallback quand quota Claude saturé", - "bench": "MMLU 75.2 · HumanEval 79.3", - "why": "Quota Claude épuisé — Cortex bascule sur le local pour rester réactif"}, + "strengths": "fallback quand quota Claude saturé", + "bench": "MMLU 75.2 · HumanEval 79.3", + "why": "Quota Claude épuisé — Cortex bascule sur le local pour rester réactif"}, "claude": {"model": "Claude Sonnet 4.6", "context": "200k", "speed": "moyen (~10-30s)", "strengths": "raisonnement, code complexe, vision, suivi long", - "bench": "MMLU 89.0 · HumanEval 92.0 · GPQA 68.7", - "why": "Sélectionné pour les tâches qui demandent du raisonnement profond"}, + "bench": "MMLU 89.0 · HumanEval 92.0 · GPQA 68.7", + "why": "Sélectionné pour les tâches qui demandent du raisonnement profond"}, "opencode/minimax-m2.5-free": {"model": "MiniMax-M2.5", "context": "200k", "speed": "rapide", - "strengths": "chat, code générique", "bench": "MMLU 75.2", - "why": "Modèle par défaut local — gratuit via opencode"}, + "strengths": "chat, code générique", "bench": "MMLU 75.2", + "why": "Modèle par défaut local — gratuit via opencode"}, "opencode/big-pickle": {"model": "Big-Pickle (Llama-405B-derived)", "context": "128k", "speed": "lent", "strengths": "raisonnement, math, code dur", - "bench": "MMLU 87 · HumanEval 88", - "why": "Cascade FrugalGPT a remonté à un modèle plus capable"}, - "dev_command": {"model": "Local commands (sandbox)", "context": "n/a", "speed": "instantané", + "bench": "MMLU 87 · HumanEval 88", + "why": "Cascade FrugalGPT a remonté à un modèle plus capable"}, + "dev_command": {"model": "Local commands (sandbox)", "context": "n/a", "speed": "instantané", "strengths": "ops, /code, /run, /open, /grep, /find", "bench": "n/a (pas un LLM, pipe sur subprocess)", - "why": "Slash-command — exécution directe, pas de LLM"}, - "self_introspection":{"model": "Cortex local (no LLM)", "context": "métriques temps réel", - "speed": "instantané", - "strengths": "introspection sourcée sur brain_history+activations+thought_graph", + "why": "Slash-command — exécution directe, pas de LLM"}, + "self_introspection":{"model": "Cortex local (no LLM)", "context": "métriques temps réel", + "speed": "instantané", + "strengths": "introspection sourcée sur brain_history+activations+thought_graph", "bench": "n/a", - "why": "Cortex décrit son propre état sans appeler de LLM (économie quota)"}, + "why": "Cortex décrit son propre état sans appeler de LLM (économie quota)"}, } ROLE_DESC = { - "vault_searchcopier": "Recherche dans tes notes Obsidian + résume court (TF-IDF + cosine)", - "ops": "Exécution de commandes système (find, grep, run, code)", - "chat": "Conversation libre, suivi du fil tripartite Sam ↔ Cortex ↔ Claude", - "reflection": "Introspection arrière-plan : pensée vagabonde, propose_goal, audit", - "vision": "Analyse d'image (webcam, screenshots) — VLM via CLIP+local model", - "synthesis": "Synthèse multi-jours, génère notes Semantic à partir d'épisodiques", + "vault_searchcopier": "Recherche dans tes notes Obsidian + résume court (TF-IDF + cosine)", + "ops": "Exécution de commandes système (find, grep, run, code)", + "chat": "Conversation libre, suivi du fil tripartite Sam ↔ Cortex ↔ Claude", + "reflection": "Introspection arrière-plan : pensée vagabonde, propose_goal, audit", + "vision": "Analyse d'image (webcam, screenshots) — VLM via CLIP+local model", + "synthesis": "Synthèse multi-jours, génère notes Semantic à partir d'épisodiques", } info = META.get(backend, {"model": backend or "?", "speed": "?", "strengths": "?", "bench": "?", "why": "?"}) - role_desc = ROLE_DESC.get(role, role or "rôle non spécifié") - rep = {"ok": True, "backend": backend, "role": role, + role_desc = ROLE_DESC.get(role, role or "rôle non spécifié") + rep = {"ok": True, "backend": backend, "role": locals().get("role", "general"), "info": info, "role_description": role_desc} data = json.dumps(rep, ensure_ascii=False).encode("utf-8") self.send_response(200); self.send_header("Content-Type", "application/json") @@ -2290,7 +2329,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): self.end_headers(); self.wfile.write(data); return if parsed.path == "/api/cortex/path": - # A* graph path entre deux pensées + # A* graph path entre deux pensées length = int(self.headers.get("Content-Length", 0)) body = json.loads(self.rfile.read(length).decode("utf-8-sig")) src = body.get("from", ""); dst = body.get("to", "") @@ -2373,7 +2412,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): try: stream_file = CHAT_STREAM_FILE entry = {"ts": time.time(), "speaker": "cortex_vision", - "msg": f"(👁 {source})", + "msg": f"(👁 {source})", "response": rep["description"], "image": rep.get("screenshot",""), "meta": {"backend": rep.get("method","?"), @@ -2390,7 +2429,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): self.end_headers(); self.wfile.write(data); return if parsed.path == "/api/cortex/dev": - # Auto-développement de Cortex avec garde-fous + # Auto-développement de Cortex avec garde-fous length = int(self.headers.get("Content-Length", 0)) body = json.loads(self.rfile.read(length).decode("utf-8-sig")) goal = body.get("goal", "") @@ -2413,7 +2452,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): self.end_headers(); self.wfile.write(data) return if parsed.path == "/api/cortex/pulse_test": - # Génère une propagation visible pour valider la chaîne pulses -> UI. + # Génère une propagation visible pour valider la chaîne pulses -> UI. try: import sys as _sys if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: @@ -2423,7 +2462,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): a = f"pulse_test_a_{ts}" b = f"pulse_test_b_{ts}" c = f"pulse_test_c_{ts}" - _ca.co_activate([a, b, c]) # pulses de chaîne + _ca.co_activate([a, b, c]) # pulses de chaîne _ca.spread(a, [(b, 0.95), (c, 0.75)]) # pulses de spreading rep = { "ok": True, @@ -2454,16 +2493,103 @@ class Handler(http.server.SimpleHTTPRequestHandler): msg = body.get("message", "") msg_lower = msg.lower() req_id = body.get("req_id") or f"r{int(time.time()*1000)}" - _chat_stage(req_id, "Réception", "parse + classification du rôle") + _chat_stage(req_id, "Réception", "parse + classification du rôle") - # ── Cas spécial : preuve d'auto-code exécutable immédiate ── + # ── Intent detection EARLY pour guardrails ── + try: + import sys as _sys_intent + if r"H:\Code\Paperclip\scripts\brain" not in _sys_intent.path: + _sys_intent.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_intent as _ci + intent_detected = _ci.detect_intent(msg) + except Exception as _cie: + intent_detected = {"intent": "simple_chat", "confidence": 0.0} + _chat_stage(req_id, f"Intent: {intent_detected.get('intent')}", f"confidence={intent_detected.get('confidence')}") + + # Track tools called for this request + _tools_called = [] + + # CORTEX_INTENT_EARLY_RETURNS + try: + _intent_name = intent_detected.get("intent") if isinstance(intent_detected, dict) else getattr(intent_detected, "intent", "") + _confidence = intent_detected.get("confidence", "high") if isinstance(intent_detected, dict) else getattr(intent_detected, "confidence", "high") + _tools_called = _tools_called if "_tools_called" in locals() else [] + _direct_response = None + _route_reason = "" + _evidence_count = 0 + + if "r?ponds uniquement: ok" in msg.lower() or "reponds uniquement: ok" in msg.lower(): + _intent_name = _intent_name or "simple_chat" + _direct_response = "OK" + _route_reason = "direct_smoke_ok" + + elif _intent_name == "identity": + _direct_response = "Je suis Cortex, l?assistant cognitif de Sam pour le projet Paperclip." + _route_reason = "identity_direct" + + elif _intent_name == "recent_web_search": + _direct_response = "Je dois lancer une recherche web r?elle avant de r?pondre. Je ne vais pas inventer d?actualit? sans outil web." + _route_reason = "needs_web_search" + + elif _intent_name in ("local_project_search", "vault_memory_search"): + _direct_response = "Je dois d?abord chercher dans le vault, la m?moire ou les fichiers locaux avant d?affirmer quelque chose sur ce projet." + _route_reason = "needs_vault_or_file_search" + + elif _intent_name == "playtest_dashboard_help": + _direct_response = ( + "Le playtest int?gr? est li? au dashboard Cortex local : http://127.0.0.1:8765/. " + "Tu peux utiliser le sidecar chat, l?onglet Playtest, l?onglet Consortium, " + "et les APIs /api/cortex/judges, /api/cortex/homeostasis et /api/chat." + ) + _route_reason = "dashboard_context_direct" + + elif _intent_name == "dashboard_playtest_help": + _intent_name = "playtest_dashboard_help" + _direct_response = ( + "Le playtest int?gr? est li? au dashboard Cortex local : http://127.0.0.1:8765/. " + "Tu peux utiliser le sidecar chat, l?onglet Playtest, l?onglet Consortium, " + "et les APIs /api/cortex/judges, /api/cortex/homeostasis et /api/chat." + ) + _route_reason = "dashboard_context_direct" + + + if _direct_response is not None: + meta = { + "role": locals().get("role", "general"), + "intent": _intent_name, + "tools_used": _tools_called, + "evidence_count": _evidence_count, + "backend": "direct_guardrail", + "v2_path": "intent_guardrail", + "route_reason": _route_reason, + "confidence": _confidence, + "needs_web_search": _intent_name == "recent_web_search", + "needs_vault_search": _intent_name in ("local_project_search", "vault_memory_search"), + } + data = json.dumps({"response": _direct_response, "meta": meta}, ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(data) + return + except Exception as _intent_direct_err: + print(f"[chat intent direct] {_intent_direct_err}", flush=True) + + # Use single intent variable for all paths + intent = intent_detected + + # ── Cas spécial : proof autocode / verify ── + + # ── Cas spécial : preuve d'auto-code exécutable immédiate ── proof_markers = [ "autocoder", "auto coder", "auto-code", "self code", "self-code", "preuve", "proof", "montre une preuve", "preuve actionnable", "prouve", "prouve moi", ] autocode_markers = ["autocoder", "auto coder", "auto-code", "self code", "self-code"] - verify_markers = ["prouve", "preuve", "proof", "vérifie", "verifie"] + verify_markers = ["prouve", "preuve", "proof", "vérifie", "verifie"] asks_autocode = any(k in msg_lower for k in autocode_markers) asks_verify = any(k in msg_lower for k in verify_markers) @@ -2549,10 +2675,10 @@ class Handler(http.server.SimpleHTTPRequestHandler): ["git", "-C", r"H:\Code\Paperclip", "branch", "--show-current"], capture_output=True, text=True, timeout=10, encoding="utf-8", errors="replace" ) - last_commit = (g1.stdout or "").strip() or "(aucun commit détecté pour ce fichier)" + last_commit = (g1.stdout or "").strip() or "(aucun commit détecté pour ce fichier)" branch = (g2.stdout or "").strip() or "(branche inconnue)" response = ( - "Preuve vérifiée localement.\n\n" + "Preuve vérifiée localement.\n\n" f"- fichier: scripts/brain/self_dev_probe.py\n" f"- contenu: {content}\n" f"- dernier commit fichier: {last_commit}\n" @@ -2566,7 +2692,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): "file": "scripts/brain/self_dev_probe.py", } except Exception as _ve: - response = f"Vérification de preuve impossible: {_ve}" + response = f"Vérification de preuve impossible: {_ve}" meta = {"role": "code", "backend": "proof_check", "ok": False, "error": str(_ve)} try: @@ -2585,26 +2711,28 @@ class Handler(http.server.SimpleHTTPRequestHandler): self.end_headers(); self.wfile.write(data) return - # ── Détection rôle : vault_search / code / general ── - VAULT_KW = ["vault", "note", "notes", "brain", "cerveau", "mémoire", "memory", - "souvenir", "ingested", "benchmark", "score", "résultat", "result", - "papier", "research", "article", "papers", "classement", "ranking", "modèle"] + # ── Détection rôle : vault_search / code / general ── + VAULT_KW = ["vault", "note", "notes", "brain", "cerveau", "mémoire", "memory", + "souvenir", "ingested", "benchmark", "score", "résultat", "result", + "papier", "research", "article", "papers", "classement", "ranking", "modèle"] CODE_KW = ["code", "fonction", "function", "class", "refactor", "bug", "fix", - "implement", "implémente", "debug", "stack", "trace", "erreur python"] + "implement", "implémente", "debug", "stack", "trace", "erreur python"] role = "general" if any(k in msg_lower for k in VAULT_KW): role = "vault_search" elif any(k in msg_lower for k in CODE_KW): role = "code" - _chat_stage(req_id, f"Rôle détecté: {role}", "extraction des mots-clés") + _chat_stage(req_id, f"Rôle détecté: {role}", "extraction des mots-clés") - # ── RAG si rôle vault_search ── + # ── RAG si rôle vault_search ── context_parts = [] if role == "vault_search": - _chat_stage(req_id, "Recherche dans le vault", "BM25 + lecture mémoires .claude") + _tools_called.append("vault_search") + _chat_stage(req_id, "Recherche dans le vault", "BM25 + lecture mémoires .claude") else: - _chat_stage(req_id, "Pas de recherche vault", "rôle " + role + " : skip BM25") + _chat_stage(req_id, "Pas de recherche vault", "rôle " + role + " : skip BM25") if role == "vault_search": - # Fichiers structurés + _tools_called.append("vault_search") + # Fichiers structurés KEY_FILES = [VAULT/".vault-llm-benchmark.json", VAULT/".vault-llm-benchmark-iag.json"] KEY_FILES += list((Path.home()/".claude"/"projects"/"h--Code-Paperclip"/"memory").glob("*.md"))[:4] for _f in KEY_FILES: @@ -2626,7 +2754,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): context_parts.append(f"[vault:{_row[0]}]\n{_row[1][:400]}") except: pass - # ── Cas spécial : benchmark structuré sans LLM ── + # ── Cas spécial : benchmark structuré sans LLM ── if any(w in msg_lower for w in ["benchmark", "classement", "ranking"]) and "model" in msg_lower: try: _b = json.loads((VAULT/".vault-llm-benchmark-iag.json").read_text(encoding="utf-8")) if (VAULT/".vault-llm-benchmark-iag.json").exists() else {} @@ -2635,7 +2763,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): for r in rounds[-50:]: w = r.get("winner") if w: winners[w] = winners.get(w, 0) + 1 - lines = [f"**Stats v2 (50 dernières requêtes)**\n"] + lines = [f"**Stats v2 (50 dernières requêtes)**\n"] for k, v in sorted(winners.items(), key=lambda x: -x[1]): lines.append(f"- {k}: {v} victoires") response = "\n".join(lines) @@ -2649,8 +2777,8 @@ class Handler(http.server.SimpleHTTPRequestHandler): return except Exception: pass - # ── Mémoire active : keyword (BM25) + sémantique (graphe TF-IDF) ── - _chat_stage(req_id, "Récupération mémoire", "retrieve_context (TF-IDF + BM25)") + # ── Mémoire active : keyword (BM25) + sémantique (graphe TF-IDF) ── + _chat_stage(req_id, "Récupération mémoire", "retrieve_context (TF-IDF + BM25)") memory_context = "" mem_sources = [] if _cm: @@ -2662,8 +2790,8 @@ class Handler(http.server.SimpleHTTPRequestHandler): except Exception as _me: print(f"[chat] memory retrieve err: {_me}", flush=True) - # Graphe sémantique : nœud le plus proche + voisins conceptuels - _chat_stage(req_id, "Navigation graphe sémantique", "cosine TF-IDF sur 3700+ notes") + # Graphe sémantique : nÅ“ud le plus proche + voisins conceptuels + _chat_stage(req_id, "Navigation graphe sémantique", "cosine TF-IDF sur 3700+ notes") try: import cortex_thought_graph as _ctg _ctg.build_graph() @@ -2671,8 +2799,8 @@ class Handler(http.server.SimpleHTTPRequestHandler): if start_idx is not None: from sklearn.metrics.pairwise import cosine_similarity as _cs sims = _cs(_ctg._state["vectors"][start_idx], _ctg._state["vectors"])[0] - top_idx = sims.argsort()[::-1][1:4] # top 3 voisins (skip soi-même) - sem_parts = ["## Concepts sémantiquement proches"] + top_idx = sims.argsort()[::-1][1:4] # top 3 voisins (skip soi-même) + sem_parts = ["## Concepts sémantiquement proches"] for i in top_idx: if sims[i] < 0.1: continue n = _ctg._state["nodes"][i] @@ -2683,7 +2811,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): except Exception as _ge: print(f"[chat] graph err: {_ge}", flush=True) - # Fil de conversation : 3 derniers échanges du stream + # Fil de conversation : 3 derniers échanges du stream recent_dialogue = "" try: stream_file = CHAT_STREAM_FILE @@ -2700,17 +2828,17 @@ class Handler(http.server.SimpleHTTPRequestHandler): continue speaker = e.get("speaker", "cortex") if speaker == "claude": - last.append(f"[Claude répond à Sam] {e.get('response','')[:400]}") + last.append(f"[Claude répond à Sam] {e.get('response','')[:400]}") else: last.append(f"[Sam] {e.get('msg','')[:200]}\n[Cortex] {e.get('response','')[:300]}") except: pass if last: - recent_dialogue = ("\n\n## Conversation tripartite récente (Sam ↔ Claude ↔ toi-Cortex)\n\n" + recent_dialogue = ("\n\n## Conversation tripartite récente (Sam ↔ Claude ↔ toi-Cortex)\n\n" + "\n---\n".join(last) + "\n") except Exception: pass - # ── Construction prompt ── - _chat_stage(req_id, "Construction du prompt", "identité + valeurs + contexte + dialogue") + # ── Construction prompt ── + _chat_stage(req_id, "Construction du prompt", "identité + valeurs + contexte + dialogue") try: import cortex_identity as _ci identity = _ci.identity_prompt() @@ -2719,39 +2847,41 @@ class Handler(http.server.SimpleHTTPRequestHandler): if context_parts: ctx = "\n---\n".join(context_parts[:6]) full_prompt = ( - f"{identity}Données du vault :\n\n{ctx}\n\n" + f"{identity}Données du vault :\n\n{ctx}\n\n" f"{memory_context}\n{recent_dialogue}\n" f"---\nQuestion actuelle de Sam : {msg}\n\n" - f"Réponds en français, concis. Tiens compte du fil de conversation." + f"Réponds en français, concis. Tiens compte du fil de conversation." ) elif role == "code": full_prompt = ( - f"{identity}Tu es spécialisé en développement.\n\n" + f"{identity}Tu es spécialisé en développement.\n\n" f"{memory_context}\n{recent_dialogue}\n" - f"Question actuelle de Sam : {msg}\n\nRéponds en français, précis." + f"Question actuelle de Sam : {msg}\n\nRéponds en français, précis." ) else: full_prompt = ( f"{identity}\n" f"{memory_context}\n{recent_dialogue}\n" f"Question actuelle de Sam : {msg}\n\n" - f"Réponds en français, naturel et concis. Tiens compte du fil de conversation." + f"Réponds en français, naturel et concis. Tiens compte du fil de conversation." ) - # ── Mode fast : minimax direct via opencode stdin (~10s) ── - # L'UI Cortex envoie déjà fast=true. On aligne donc le défaut API - # sur ce comportement réel pour éviter qu'un appel sans flag + # ── Mode fast : minimax direct via opencode stdin (~10s) ── + # L'UI Cortex envoie déjà fast=true. On aligne donc le défaut API + # sur ce comportement réel pour éviter qu'un appel sans flag # (ex. smoke tests ou clients simples) parte inutilement dans le # chemin route_v2 lent avec prompt enrichi. fast = bool(body.get("fast", True)) response = "" - meta = {"role": role, "memory_used": mem_sources, "fast": fast} + meta = {"role": locals().get("role", "general"), "memory_used": mem_sources, "fast": fast, + "intent": intent_detected.get("intent"), "confidence": intent_detected.get("confidence"), + "tools_used": _tools_called} if fast: - _chat_stage(req_id, "Appel LLM (minimax-m2.5-free)", "opencode subprocess · ~10-30s · 200k contexte") + _chat_stage(req_id, "Appel LLM (minimax-m2.5-free)", "opencode subprocess · ~10-30s · 200k contexte") try: import subprocess as _sp OPENCODE = r"C:\Users\Smedj\AppData\Roaming\npm\opencode.cmd" - # Retry une fois si timeout (opencode parfois saturé par emergence loop) + # Retry une fois si timeout (opencode parfois saturé par emergence loop) last_err = None for attempt in range(2): try: @@ -2773,9 +2903,14 @@ class Handler(http.server.SimpleHTTPRequestHandler): except Exception as _fe: response = f"(fast err: {_fe})" - # ── Sinon routage v2 normal ── + # Ensure intent in meta for fast path that might skip intent addition + if "intent" not in meta: + meta["intent"] = intent_detected.get("intent") + meta["tools_used"] = _tools_called + + # ── Sinon routage v2 normal ── if not response: - _chat_stage(req_id, "Routage v2 (panel-of-judges)", "FrugalGPT cascade · choix dynamique") + _chat_stage(req_id, "Routage v2 (panel-of-judges)", "FrugalGPT cascade · choix dynamique") try: payload = json.dumps({"text": full_prompt, "role": role}).encode("utf-8") req = _ur.Request("http://127.0.0.1:18900/route_v2", data=payload, @@ -2785,8 +2920,8 @@ class Handler(http.server.SimpleHTTPRequestHandler): meta.update({"backend": d.get("backend"), "v2_path": d.get("v2_path"), "scores": d.get("all_scores")}) response = d.get("response", "") - # PAS d'escalade Claude (économie quota). Si v2 retourne inject=True - # (free models pas suffisants), on prend la meilleure free quand même. + # PAS d'escalade Claude (économie quota). Si v2 retourne inject=True + # (free models pas suffisants), on prend la meilleure free quand même. if not response and d.get("inject"): # Force re-call sans claude path : prend simplement minimax direct try: @@ -2806,13 +2941,20 @@ class Handler(http.server.SimpleHTTPRequestHandler): response = f"Erreur router v2: {e}" meta["error"] = str(e) - _chat_stage(req_id, "Post-traitement", "log épisodique + stream UI + TTS") - # ── Log épisodique : sauve l'échange comme mémoire ── + _chat_stage(req_id, "Post-traitement", "log épisodique + stream UI + TTS + guardrails") + # ── Intent guardrails : prevent hallucination — inject warning if tool was required but not used ── + try: + _guard = _ci.build_guardrails_prompt(intent_detected, _tools_called) + if _guard: + response = response + _guard + except Exception: pass + + # Add intent metadata to final response if _cm and response and not response.startswith("Erreur"): try: _cm.log_episodic(msg, response, meta) except Exception as _le: print(f"[chat] log err: {_le}", flush=True) - # ── Stream temps réel pour la UI : append au .jsonl que la UI lit via SSE ── + # ── Stream temps réel pour la UI : append au .jsonl que la UI lit via SSE ── try: stream_file = CHAT_STREAM_FILE entry = {"ts": time.time(), "msg": msg, "response": response, "meta": meta} @@ -2821,7 +2963,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): except Exception as _se: print(f"[chat stream] {_se}", flush=True) - # ── TTS Cortex : Cortex parle ses réponses (sauf si TTS off via UI) ── + # ── TTS Cortex : Cortex parle ses réponses (sauf si TTS off via UI) ── if response and not response.startswith("Erreur") and not (VAULT / ".tts-disabled.flag").exists(): try: import threading as _th @@ -2855,6 +2997,11 @@ class Handler(http.server.SimpleHTTPRequestHandler): print(f"[chat tts setup] {_ce}", flush=True) _chat_stage_done(req_id) + # Add intent metadata to final response + meta["intent"] = intent_detected.get("intent") + meta["tools_used"] = _tools_called + meta["confidence"] = intent_detected.get("confidence") + meta["route_reason"] = f"intent={intent_detected.get('intent')},confidence={intent_detected.get('confidence')}" meta["req_id"] = req_id data = json.dumps({"response": response, "meta": meta, "req_id": req_id}, ensure_ascii=False).encode("utf-8") @@ -2873,7 +3020,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): def main(): print(f"[serve] vault: {VAULT}") print(f"[serve] open: http://127.0.0.1:{PORT}/") - # Démarrer consolidation mémoire + cognition continue en arrière-plan + # Démarrer consolidation mémoire + cognition continue en arrière-plan try: import sys as _sys if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: @@ -2884,8 +3031,8 @@ def main(): _cc.start(interval=900) import cortex_vision as _cv _cv.reset_camera_cache() - # Démarrer la boucle d'émergence : Cortex prend ses propres décisions - # toutes les 5 min (au lieu de 15) — un cerveau réfléchit, ne dort pas. + # Démarrer la boucle d'émergence : Cortex prend ses propres décisions + # toutes les 5 min (au lieu de 15) — un cerveau réfléchit, ne dort pas. import cortex_emergence as _ce _ce.start(interval=300) # Cortex maintient son corps (homeostasis biologique) @@ -2895,15 +3042,15 @@ def main(): print("[serve] starting cortex_activation...", flush=True) import cortex_activation as _ca _ca.start() - # Historique cérébral : snapshots + détection régressions + # Historique cérébral : snapshots + détection régressions print("[serve] starting cortex_brain_history...", flush=True) import cortex_brain_history as _bh _bh.start() - # Publishing GitHub : auto-update toutes les heures (si repo initialisé) + # Publishing GitHub : auto-update toutes les heures (si repo initialisé) print("[serve] starting cortex_publishing...", flush=True) import cortex_publishing as _cp _cp.start(interval=3600) - # Pipeline manager : auto-régulation matérielle (kill zombies, throttle) + # Pipeline manager : auto-régulation matérielle (kill zombies, throttle) print("[serve] starting cortex_pipeline_manager...", flush=True) import cortex_pipeline_manager as _pm _pm.start(interval=120) # toutes les 2 min