diff --git a/scripts/brain/cortex_intent.py b/scripts/brain/cortex_intent.py index f943818830..951c681c19 100644 --- a/scripts/brain/cortex_intent.py +++ b/scripts/brain/cortex_intent.py @@ -35,6 +35,17 @@ def _norm(text: str) -> str: def detect_intent(message: str) -> CortexIntent: m = _norm(message) + if m.startswith("/code") and any(k in m for k in [ + "playtest", "app", "application", "html", "interface", + "calculatrice", "todo", "kanban", "dashboard" + ]): + return CortexIntent( + intent="playtest_code_task", + confidence="high", + route_reason="playtest_builder_direct", + dashboard_context=DASHBOARD_CONTEXT, + ) + 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", diff --git a/scripts/brain/dashboard/brain_gpu.html b/scripts/brain/dashboard/brain_gpu.html index 7a9ab10b8e..2f1afc74b3 100644 --- a/scripts/brain/dashboard/brain_gpu.html +++ b/scripts/brain/dashboard/brain_gpu.html @@ -171,14 +171,14 @@
- Activation neurale réel + Activation neurale récente réel
- Apprentissages Hebbian réel disque + Traces Hebbian récentes réel disque
@@ -196,7 +196,7 @@
-
Concepts qui grandissent world model
+
Concepts consolidés / en croissance world model
@@ -393,6 +393,12 @@
Le prochain arbitrage apparaîtra ici
Quand Cortex passe par le panel-of-judges, vous verrez ici le backend retenu, le chemin, puis les rounds récents complets juste en dessous.
+
+ + + + +
@@ -421,6 +427,19 @@ +
scroll=zoom · drag=rotate · GPU
@@ -2093,22 +2112,40 @@ window._toggleConsortium = () => { window._setChatSidecarTab('consortium'); if (typeof window._refreshConsortium === 'function') window._refreshConsortium(); }; -window._playtestLoad = (url) => { +window._playtestLoad = async (url) => { const inp = document.getElementById('playtest-url'); const target = url || (inp && inp.value.trim()); if (!target) return; if (inp) inp.value = target; const f = document.getElementById('playtest-frame'); - if (f) f.src = target; const info = document.getElementById('playtest-info'); - if (info) info.innerHTML = 'Chargé : ' + target.replace(/'; + if (info) info.innerHTML = 'Chargement : ' + target.replace(/'; + try { + if (/^https?:\/\/127\.0\.0\.1:8765\/playtests\/.+\.html$/i.test(target) || /^\/playtests\/.+\.html$/i.test(target)) { + const probe = await fetch(target, {cache: 'no-store'}); + if (!probe.ok) throw new Error('HTTP ' + probe.status); + } + if (f) f.src = target; + if (info) info.innerHTML = 'Chargé : ' + target.replace(/'; + } catch (e) { + if (info) info.innerHTML = 'Échec Playtest : ' + String(e.message || e).replace(/'; + } }; window._playtestSetFromCode = (result) => { - // result = sortie du dev_command /code. On cherche un chemin de fichier - // créé/modifié dans la sortie et on le propose en preview. const info = document.getElementById('playtest-info'); if (!info) return; - // Heuristique : fichiers HTML, MD, ou path générique + if (result && typeof result === 'object') { + const meta = result.meta || {}; + if (meta.playtest_url || meta.auto_open_playtest) { + const target = meta.playtest_url || (result.response && (result.response.match(/https?:\/\/127\.0\.0\.1:8765\/playtests\/[^\s]+\.html/i) || [])[0]); + if (target) { + info.innerHTML = `Livrable Playtest généré : ${target.replace(/`; + window._togglePlaytest(); + window._playtestLoad(target); + return; + } + } + } const m = (result || '').match(/[A-Z]:[\\\/][^\s'"`]+\.(html|md|js|ts|py)/i); if (m) { const p = m[0]; @@ -2121,6 +2158,51 @@ window._playtestSetFromCode = (result) => { } }; +window._consortiumUi = { hideEmpty: true, lastOnly: false }; +window._toggleConsortiumEmpty = () => { + window._consortiumUi.hideEmpty = !window._consortiumUi.hideEmpty; + const btn = document.getElementById('consortium-toggle-empty'); + if (btn) btn.textContent = window._consortiumUi.hideEmpty ? 'afficher réponses vides' : 'masquer réponses vides'; + window._renderConsortium(window._consortiumCache || {ok:false}); +}; +window._toggleConsortiumLastOnly = () => { + window._consortiumUi.lastOnly = !window._consortiumUi.lastOnly; + const btn = document.getElementById('consortium-toggle-last'); + if (btn) btn.textContent = window._consortiumUi.lastOnly ? 'tous les rounds' : 'dernier round seulement'; + window._renderConsortium(window._consortiumCache || {ok:false}); +}; +window._consortiumShowJson = () => { + const raw = JSON.stringify(window._consortiumCache || {}, null, 2); + window._openBrainDetail({ + title: 'Consortium JSON brut', + subtitle: 'Source: /api/cortex/judges', + chips: [{label:'raw json', color:'#9fc0ff'}], + body: raw, + }); +}; +window._consortiumExplainWhy = () => { + const meta = window._lastChatMeta || {}; + const lines = [ + `intent: ${meta.intent || 'n/a'}`, + `complexité: ${meta.complexity || 'n/a'}`, + `routing_decision: ${meta.routing_decision || meta.v2_path || 'n/a'}`, + `backend: ${meta.selected_backend || meta.backend || 'n/a'}`, + `raison: ${meta.selection_reason || meta.route_reason || 'n/a'}`, + `router_used: ${String(!!meta.router_used)}`, + `judge_used: ${String(!!meta.judge_used)}`, + `history: ${meta.history_used ? 'oui' : 'non'} (${meta.history_count || 0})`, + ]; + window._openBrainDetail({ + title: 'Pourquoi ce modèle ?', + subtitle: 'Métadonnées du dernier tour', + chips: [ + {label:`backend ${(meta.selected_backend || meta.backend || 'n/a')}`, color:'#9fc0ff'}, + {label:`route ${(meta.routing_decision || meta.v2_path || 'n/a')}`, color:'#8fd5b8'}, + ], + body: lines.join('\n'), + }); +}; + window._renderConsortium = (data) => { const ranking = document.getElementById('consortium-ranking'); const rounds = document.getElementById('consortium-rounds'); @@ -2130,27 +2212,46 @@ window._renderConsortium = (data) => { rounds.innerHTML = '
Impossible de lire le consortium pour le moment.
'; return; } - ranking.innerHTML = (data.ranking || []).slice(0, 5).map((row, idx) => - `${idx + 1}. ${row.model}${row.win_rate}% · ${row.avg_latency_s}s` - ).join(''); - rounds.innerHTML = (data.rounds || []).slice().reverse().map((round, idx) => { + const rankingRows = (data.ranking || []).filter(row => Number(row.rounds || 0) > 0); + ranking.innerHTML = rankingRows.length + ? rankingRows.slice(0, 5).map((row, idx) => + `${idx + 1}. ${row.model}${row.win_rate}% · ${row.avg_latency_s}s` + ).join('') + : 'Pas assez de scores réels pour afficher un classement fiable.'; + let sourceRounds = (data.rounds || []).slice().reverse(); + if (window._consortiumUi.lastOnly) sourceRounds = sourceRounds.slice(0, 1); + rounds.innerHTML = sourceRounds.map((round, idx) => { const responses = round.responses || {}; const latencies = round.latencies || {}; const scores = round.scores || {}; - const models = Object.keys(responses); + const statuses = round.statuses || {}; + let models = Object.keys({...responses, ...latencies, ...scores, ...statuses}); + if (window._consortiumUi.hideEmpty) { + models = models.filter((model) => { + const resp = responses[model]; + return !!(resp && String(resp).trim()); + }); + } const rows = models.map((model) => { const isWinner = model === round.winner; const score = scores[model]; const latency = latencies[model]; - const why = score != null - ? `score ${Number(score).toFixed ? Number(score).toFixed(1) : score}` - : `latence ${latency != null ? Number(latency).toFixed(1) + 's' : 'n/a'}`; + const rawStatus = statuses[model]; + const inferredStatus = rawStatus || (!responses[model] ? (latency >= 59 ? 'timeout' : 'empty_response') : 'ok'); + const statusColor = inferredStatus === 'ok' ? '#8fd5b8' : inferredStatus.includes('timeout') ? '#ffb36b' : inferredStatus.includes('error') ? '#ff8a8a' : '#d7b36f'; + const finalScore = round.final_scores && round.final_scores[model]; + const why = [ + `status ${inferredStatus}`, + latency != null ? `latence ${Number(latency).toFixed(1)}s` : 'latence n/a', + score != null ? `score juge ${Number(score).toFixed(1)}` : null, + finalScore != null ? `score final ${Number(finalScore).toFixed(2)}` : null, + ].filter(Boolean).join(' · '); return `
${model}${isWinner ? ' · gagnant' : ''}
-
${why}
+
${why}
-
${(responses[model] || '(vide)').replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c]))}
+
${(responses[model] || 'empty_response').replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c]))}
`; }).join(''); const question = (round.question || '').replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); @@ -2160,7 +2261,8 @@ window._renderConsortium = (data) => {
winner ${round.winner || 'n/a'}
${question || '(question indisponible)'}
-
${rows}
+
${round.route || round.v2_path || 'route non documentée'}
+
${rows || '
Tous les candidats ont été masqués par le filtre actuel.
'}
`; }).join('') || '
Aucun round récent.
'; }; @@ -2291,6 +2393,61 @@ function _shortName(p) { if (!p) return '?'; return p.split('/').pop().split('\\').pop().replace(/\.md$/, '').slice(0, 32); } +function _brainAttr(obj) { + return JSON.stringify(obj) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>'); +} +window._openBrainDetail = (opts) => { + const modal = document.getElementById('brain-detail-modal'); + if (!modal) return; + document.getElementById('brain-detail-title').textContent = opts.title || 'Détail'; + document.getElementById('brain-detail-subtitle').textContent = opts.subtitle || ''; + const meta = document.getElementById('brain-detail-meta'); + meta.innerHTML = (opts.chips || []).map(ch => + `${String(ch.label || '').replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c]))}` + ).join(''); + document.getElementById('brain-detail-body').textContent = opts.body || ''; + modal.style.display = 'flex'; +}; +window._closeBrainDetail = () => { + const modal = document.getElementById('brain-detail-modal'); + if (modal) modal.style.display = 'none'; +}; +window._showBrainSourceDetail = async (item) => { + const chips = []; + if (item.type) chips.push({label:`type ${item.type}`, color:'#9fc0ff'}); + if (item.score != null) chips.push({label:`score ${Number(item.score).toFixed(3)}`, color:'#8fd5b8'}); + if (item.weight != null) chips.push({label:`force ${Number(item.weight).toFixed(3)}`, color:'#f6ad55'}); + if (item.learningRate != null) chips.push({label:`learning_rate ${Number(item.learningRate).toFixed(3)}`, color:'#fbd38d'}); + if (item.coactivationCount != null) chips.push({label:`coactivations ${item.coactivationCount}`, color:'#d6bcfa'}); + if (item.lastSeen) chips.push({label:`last_seen ${item.lastSeen}`, color:'#a0aec0'}); + window._openBrainDetail({ + title: item.title || _shortName(item.source || item.a || item.b || ''), + subtitle: item.source || [item.a, item.b].filter(Boolean).join(' ↔ ') || 'source introuvable', + chips, + body: 'Chargement de la source markdown…', + }); + let excerpt = 'source introuvable'; + if (item.source) { + try { + const r = await fetch(`/api/node-content?id=${encodeURIComponent(item.source)}`); + const d = await r.json(); + excerpt = (d.content || 'source introuvable').slice(0, 2400); + } catch (e) { + excerpt = 'source introuvable'; + } + } + const details = []; + if (item.description) details.push(item.description); + if (item.a || item.b) details.push(`Liaison: ${(item.a || '?')} ↔ ${(item.b || '?')}`); + if (item.tooltip) details.push(item.tooltip); + details.push('\nExtrait markdown:\n' + excerpt); + document.getElementById('brain-detail-body').textContent = details.join('\n\n'); +}; function _kindOf(p) { // Devine la nature d'un nœud à partir de son chemin const s = (p||'').toLowerCase(); @@ -2320,12 +2477,15 @@ function refreshCerebralView() { + `
Activation : ${v.toFixed(3)} (sur 1.0)
` + `
Demi-vie restante avant extinction : ~${Math.max(0, halfLife)} s
` + `
Pourquoi éveillé : un retrieve_context, un A* ou la boucle de pensée vagabonde l'a touché. Plus la valeur est haute, plus il vient d'être réactivé.
`; - return `
+ const detail = _brainAttr({title:_shortName(p), source:p, type:k, score:Number(v.toFixed(3)), lastSeen:'récent', description:'Activation neurale récente. Cette barre peut décroître avec le temps.'}); + return `
+
${_shortName(p)}
${(v).toFixed(2)} +
`; }).join(''); } @@ -2346,7 +2506,8 @@ function refreshCerebralView() { + `
Force : ${e.strength.toFixed(3)}
` + `
Soit ~${coActivations} co-activations cumulées (HEBBIAN_LR=0.01).
` + `
Plus on les pense ensemble, plus le lien grossit. Persisté sur disque, survit aux redémarrages. C'est la mémoire long terme.
`; - return `
+ const detail = _brainAttr({title:`${_shortName(e.a)} ↔ ${_shortName(e.b)}`, source:e.a, a:e.a, b:e.b, type:'hebbian', weight:Number(e.strength.toFixed(3)), learningRate:0.01, coactivationCount:coActivations, lastSeen:'récent', description:'Ces deux notes ont été co-activées plusieurs fois. Force actuelle persistée sur disque.', tooltip:`Ces deux notes ont été co-activées ${coActivations} fois. Force actuelle: ${e.strength.toFixed(3)}.`}); + return `
${_shortName(e.a)} @@ -2426,6 +2587,7 @@ function refreshCerebralView() { // ── 4b. World model — concepts qui grandissent (croissance Hebbian récente) ── const wcont = document.getElementById('cb-worldmodel'); if (wcont) { + window._worldModelStable = window._worldModelStable || {}; // On combine : top hebbian edges (force) + activations actuelles pour deviner // les concepts en croissance dans le world model. const heb = (acts.top_hebbian_edges || []).slice(0, 12); @@ -2435,7 +2597,12 @@ function refreshCerebralView() { concept[e.b] = (concept[e.b] || 0) + e.strength; }); active.forEach(([p, v]) => { concept[p] = (concept[p] || 0) + v * 0.5; }); - const top = Object.entries(concept).sort((a,b) => b[1]-a[1]).slice(0, 5); + Object.entries(concept).forEach(([p, score]) => { + const prev = window._worldModelStable[p] || 0; + window._worldModelStable[p] = Math.max(prev, score); + }); + const stableEntries = Object.entries(window._worldModelStable).sort((a,b) => b[1]-a[1]).slice(0, 5); + const top = stableEntries.length ? stableEntries : Object.entries(concept).sort((a,b) => b[1]-a[1]).slice(0, 5); if (!top.length) { wcont.innerHTML = '
Apprentissage stagne — Cortex n\'a rien renforcé récemment
'; } else { @@ -2446,7 +2613,8 @@ function refreshCerebralView() { + `
Score de croissance : ${score.toFixed(3)}
` + `
= activation cumulée + force des liens Hebbian.
` + `
Plus ce score grandit, plus Cortex consolide ce concept dans son world model. Si tous les scores sont bas → l'apprentissage est en pause (augmente l'activité ou attends la pensée vagabonde).
`; - return `
+ const detail = _brainAttr({title:_shortName(p), source:p, type:'concept_consolidated', score:Number(score.toFixed(3)), lastSeen:'stable', description:'Concept consolidé ou en croissance. Cette vue garde la meilleure consolidation observée et ne décroît pas artificiellement.'}); + return `
${_shortName(p)} @@ -2570,10 +2738,11 @@ function _renderConsortiumLive(source) { const stream = source || window._dashboardLastStream || {}; const lastChat = stream.last_chat || {}; const router = stream.router || {}; - const backend = (lastChat.meta && lastChat.meta.backend) || router.v2_last_winner; - const route = (lastChat.meta && lastChat.meta.v2_path) || router.v2_last_path || 'local'; + const meta = lastChat.meta || {}; + const backend = meta.selected_backend || meta.backend || router.v2_last_winner; + const route = meta.routing_decision || meta.v2_path || router.v2_last_path || 'local'; const scores = lastChat.meta && lastChat.meta.scores; - const isJudge = /panel|judge|frugal|v2/i.test(route || ''); + const isJudge = !!meta.judge_used || /panel|judge|frugal|v2|consensus/i.test(route || ''); if (!backend) { status.textContent = 'pas encore de décision'; winner.textContent = 'Aucun backend récent'; @@ -2583,12 +2752,12 @@ function _renderConsortiumLive(source) { pill.style.color = '#778'; return; } - status.textContent = isJudge ? 'consortium actif' : 'réponse directe'; + status.textContent = isJudge ? 'consortium actif' : (meta.router_used ? 'router actif' : 'panel non utilisé'); winner.textContent = backend; path.textContent = `Chemin ${route}`; note.textContent = scores && typeof scores === 'object' && Object.keys(scores).length ? `Scores observés : ${Object.entries(scores).map(([k,v]) => `${k} ${Number(v).toFixed ? Number(v).toFixed(1) : v}`).join(' · ')}` - : 'Pas de scores détaillés sur ce tour; le chemin ou la latence ont probablement suffi à décider.'; + : (meta.selection_reason || 'Pas de scores détaillés sur ce tour; le chemin, la latence ou un guardrail ont suffi à décider.'); pill.textContent = isJudge ? `consortium ${backend}` : `chat ${backend}`; pill.style.color = isJudge ? '#93b7ff' : '#8fd5b8'; } @@ -3018,8 +3187,8 @@ async function _fetchLastEmergence() { const ce = document.getElementById('hb-em-content') || document.getElementById('cb-emergence'); if (!ce) return; const ageS = Math.round(Date.now()/1000 - (last.ts||0)); - ce.innerHTML = `
action: ${last.action} · il y a ${_fmtAge(ageS)}
` + - `
${(last.response||'').slice(0,300)}
`; + ce.innerHTML = `
dernière action: ${last.action || 'n/a'} · il y a ${_fmtAge(ageS)}
` + + `
${(last.response||'').slice(0,300) || 'résultat indisponible'}
`; window._cbLastEmergenceTs = (last.ts||0) * 1000; } catch(e) {} } @@ -3673,7 +3842,7 @@ window.sendChat = async () => { appendChatMessage({speaker:'cortex', msg, response, meta:d.meta || {}}); // Hook playtest pour chat normal aussi (Sam : "Cortex a répondu mais playtest n'a pas bougé") if (typeof window._playtestSetFromCode === 'function') { - try { window._playtestSetFromCode(response); } catch(e) {} + try { window._playtestSetFromCode(d); } catch(e) {} } } catch(e) { if (pending?.text) { @@ -3684,6 +3853,11 @@ window.sendChat = async () => { } } }; +document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + window._closeBrainDetail(); + } +}); let _calibRecog = null, _calibActive = false; let _calibGoodCount = 0; const CALIB_TARGET = 5; // 5 segments avec bon score = profil validé diff --git a/scripts/brain/dashboard/serve.py b/scripts/brain/dashboard/serve.py index eac57e9f5f..48bcbb1f2a 100644 --- a/scripts/brain/dashboard/serve.py +++ b/scripts/brain/dashboard/serve.py @@ -16,6 +16,7 @@ import json import time import os import socketserver +import subprocess import sys import threading from pathlib import Path @@ -31,6 +32,10 @@ HERE = Path(__file__).parent PORT = int(os.environ.get("BRAIN_DASHBOARD_PORT", "8765")) CHAT_STREAM_FILE = VAULT / ".cortex-chat-stream.jsonl" EMERGENCE_STREAM_FILE = VAULT / ".cortex-emergence-stream.jsonl" +PLAYTEST_DIR = HERE / "playtests" +PLAYTEST_BASE_URL = f"http://127.0.0.1:{PORT}/playtests" +ROUTER_BENCHMARK_FILE = HERE / "state" / "router_benchmarks.json" +OPENCODE_CMD = Path(r"C:\Users\Smedj\AppData\Roaming\npm\opencode.cmd") GRAPH_FILE = VAULT / ".vault-graph.json" LAYOUT_FILE = VAULT / ".vault-graph-layout.json" @@ -44,6 +49,44 @@ JEPA_STATUS = VAULT / ".vault-jepa-status.json" _cache: dict = {"snapshot": None, "snapshot_mtime": 0} _lock = threading.Lock() +CONFIGURED_MODEL_PRIORS = { + "minimax_fast": { + "strengths": ["fast", "french", "chat", "summarization"], + "weaknesses": ["deep_reasoning", "complex_code"], + "cost": "low", + }, + "gpt_5_nano": { + "strengths": ["structured", "fast_reasoning"], + "weaknesses": [], + "cost": "low", + }, + "big_pickle": { + "strengths": ["math", "short_factual"], + "weaknesses": [], + "cost": "low", + }, + "hy3_preview": { + "strengths": ["reasoning"], + "weaknesses": [], + "cost": "low", + }, + "nemotron_3_super": { + "strengths": ["reasoning", "long_answer"], + "weaknesses": [], + "cost": "low", + }, + "playtest_builder": { + "strengths": ["playtest_html"], + "weaknesses": [], + "cost": "low", + }, + "direct_guardrail": { + "strengths": ["truth", "safe_direct_answer"], + "weaknesses": [], + "cost": "low", + }, +} + def _is_chat_entry(entry: dict | None) -> bool: if not isinstance(entry, dict): @@ -56,6 +99,316 @@ 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") + +def _write_json_atomic(path: Path, payload: dict): + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp, path) + + +def _safe_int(value, default: int = 0) -> int: + try: + return int(value) + except Exception: + return default + + +def _safe_float(value, default: float = 0.0) -> float: + try: + return float(value) + except Exception: + return default + + +def _infer_complexity(message: str, intent_name: str = "") -> str: + text = (message or "").strip().lower() + if intent_name in ("recent_web_search", "playtest_dashboard_help", "identity"): + return "simple" + if intent_name == "playtest_code_task": + return "hard" + if len(text) > 500: + return "hard" + hard_markers = [ + "architecture", "stabiliser", "stabilize", "debug", "diagnostic", + "plan", "planifie", "pourquoi", "analyse", "compare", "router", + "judge", "consortium", "server", "crash", "timeout", + ] + medium_markers = [ + "explique", "comment", "résume", "resume", "problème", "probleme", + "mémoire", "memoire", "vault", "fichier", "repo", + ] + if any(marker in text for marker in hard_markers): + return "hard" + if any(marker in text for marker in medium_markers): + return "medium" + return "simple" + + +def _is_simple_fact_question(message: str) -> bool: + text = (message or "").strip().lower() + if not text or len(text) > 160: + return False + if any(marker in text for marker in ["```", "/code", "debug", "architecture", "plan", "analyse", "compare"]): + return False + return any(text.startswith(prefix) for prefix in [ + "quelle", "quel", "qui", "où", "ou", "quand", "combien", "c'est quoi", "explique-moi brièvement", + ]) + + +def _is_playtest_code_request(message: str) -> bool: + text = (message or "").strip().lower() + if not text.startswith("/code"): + return False + markers = ["playtest", "app", "application", "html", "interface", "calculatrice", "todo", "kanban", "dashboard"] + return any(marker in text for marker in markers) + + +def _read_recent_history(max_turns: int = 4, include_responses: bool = True) -> list[dict]: + turns: list[dict] = [] + if not CHAT_STREAM_FILE.exists(): + return turns + try: + lines = CHAT_STREAM_FILE.read_text(encoding="utf-8", errors="replace").splitlines()[-120:] + except Exception: + return turns + for raw in reversed(lines): + try: + entry = json.loads(raw) + except Exception: + continue + if not _is_chat_entry(entry): + continue + msg = (entry.get("msg") or "").strip() + response = (entry.get("response") or "").strip() + if not msg and not response: + continue + turns.append({ + "msg": msg[:400], + "response": response[:500] if include_responses else "", + "speaker": entry.get("speaker") or "cortex", + "meta": entry.get("meta") or {}, + }) + if len(turns) >= max_turns: + break + turns.reverse() + return turns + + +def _history_prompt(turns: list[dict], for_code: bool = False) -> tuple[str, int]: + if not turns: + return "", 0 + chunks = [] + for turn in turns[-5:]: + msg = (turn.get("msg") or "").strip() + response = (turn.get("response") or "").strip() + if not msg: + continue + if for_code: + chunks.append(f"Sam: {msg[:220]}") + else: + block = f"Sam: {msg[:220]}" + if response: + block += f"\nCortex: {response[:260]}" + chunks.append(block) + if not chunks: + return "", 0 + return "\n\nHistorique utile récent:\n" + "\n---\n".join(chunks), len(chunks) + + +def _extract_html_document(text: str) -> str: + raw = (text or "").strip() + if not raw: + return "" + if raw.startswith("```"): + parts = raw.split("```") + for part in parts: + candidate = part.strip() + if "= 0: + raw = raw[start:] + end = raw.lower().rfind("") + if end >= 0: + raw = raw[:end + len("")] + return raw.strip() + + +def _fallback_playtest_html(message: str) -> str: + title = "Playtest Cortex" + if "calculatrice" in (message or "").lower(): + title = "Calculatrice Playtest" + body = """ +
+
+

Calculatrice locale

+

Fallback autonome généré par Cortex. Aucun CDN, aucun backend.

+
0
+
+
+
+ +""" + else: + body = f""" +
+
+

{title}

+

Fallback HTML autonome généré après un échec LLM.

+ +
+ + +
+

Prêt.

+
+
+ +""" + return f""" + + + + + {title} + + +{body} + +""" + + +def _write_playtest_file(html: str) -> tuple[Path, str]: + PLAYTEST_DIR.mkdir(parents=True, exist_ok=True) + stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"generated_{stamp}.html" + file_path = PLAYTEST_DIR / filename + file_path.write_text(html, encoding="utf-8") + return file_path, f"{PLAYTEST_BASE_URL}/{filename}" + + +def _playtest_file_from_name(name: str) -> Path | None: + if not name or "/" in name or "\\" in name or ".." in name or ":" in name: + return None + if not name.lower().endswith(".html"): + return None + target = (PLAYTEST_DIR / name).resolve() + try: + target.relative_to(PLAYTEST_DIR.resolve()) + except Exception: + return None + return target + + +def _load_router_benchmarks() -> dict: + data = _safe_load(ROUTER_BENCHMARK_FILE) + if isinstance(data, dict) and "backends" in data: + return data + return {"updated_at": None, "backends": {}} + + +def _update_router_benchmarks(backend: str, latency_s: float, status: str, domains: list[str], judge_score: float | None = None): + if not backend: + return + data = _load_router_benchmarks() + backends = data.setdefault("backends", {}) + item = backends.setdefault(backend, { + "calls": 0, + "success": 0, + "empty_responses": 0, + "timeouts": 0, + "errors": 0, + "avg_latency_s": 0.0, + "judge_score_avg": 0.0, + "judge_score_count": 0, + "domains": {}, + }) + item["calls"] += 1 + prev_calls = max(item["calls"] - 1, 0) + if status == "ok": + item["success"] += 1 + elif status == "timeout": + item["timeouts"] += 1 + elif status == "empty": + item["empty_responses"] += 1 + else: + item["errors"] += 1 + latency_s = max(0.0, _safe_float(latency_s)) + item["avg_latency_s"] = ((item["avg_latency_s"] * prev_calls) + latency_s) / max(item["calls"], 1) + if judge_score is not None: + count = _safe_int(item.get("judge_score_count")) + avg = _safe_float(item.get("judge_score_avg")) + item["judge_score_avg"] = ((avg * count) + judge_score) / (count + 1) + item["judge_score_count"] = count + 1 + for domain in domains or []: + if not domain: + continue + item["domains"][domain] = _safe_int(item["domains"].get(domain)) + 1 + data["updated_at"] = dt.datetime.now().isoformat() + try: + _write_json_atomic(ROUTER_BENCHMARK_FILE, data) + except Exception as exc: + print(f"[router benchmarks] {exc}", flush=True) + # Heartbeat global : timestamp de démarrage du serveur (pour uptime) SERVER_STARTED_AT = time.time() @@ -460,6 +813,433 @@ class Handler(http.server.SimpleHTTPRequestHandler): try: self.close_connection = True except Exception: pass + def _send_json(self, payload: dict, status: int = 200): + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + 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) + + def _send_text_file(self, path: Path, ctype: str): + data = path.read_bytes() + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(data) + + def _controlled_chat_error(self, message: str, intent_name: str = "simple_chat", req_id: str = "", extra_meta: dict | None = None) -> dict: + meta = { + "backend": "error_guard", + "intent": intent_name or "simple_chat", + "error": message, + "tools_used": [], + "confidence": "low", + "complexity": "medium", + "routing_decision": "controlled_error", + "router_used": False, + "judge_used": False, + "selected_backend": "error_guard", + "selection_reason": "controlled exception captured by /api/chat guard", + "history_used": False, + "history_count": 0, + "benchmark_basis": { + "internal_observed": False, + "configured_priors": True, + "official_sources": [], + }, + } + if req_id: + meta["req_id"] = req_id + if extra_meta: + meta.update(extra_meta) + return {"response": f"Erreur contrôlée: {message}", "meta": meta, "req_id": req_id} + + def _call_opencode_chat(self, prompt: str, timeout_s: int = 35, model_id: str = "opencode/minimax-m2.5-free") -> tuple[str, str | None]: + if not OPENCODE_CMD.exists(): + return "", "opencode_unavailable" + try: + run = subprocess.run( + [str(OPENCODE_CMD), "run", "--model", model_id, "-"], + input=prompt, + capture_output=True, + text=True, + timeout=timeout_s, + encoding="utf-8", + errors="replace", + ) + lines = [ + line for line in (run.stdout or "").splitlines() + if line.strip() and not line.startswith(">") and "\x1b" not in line and "build" not in line.lower() + ] + response = "\n".join(lines).strip() + return response, None if response else "empty_response" + except subprocess.TimeoutExpired: + return "", "timeout" + except Exception as exc: + return "", str(exc) + + def _build_chat_payload(self, msg: str, intent_name: str, role: str, history_text: str, extra_context: str = "") -> str: + try: + import cortex_identity as _cortex_identity + identity = _cortex_identity.identity_prompt() + except Exception: + identity = "Tu es Cortex, assistant local fiable pour Paperclip." + parts = [ + identity.strip(), + "Réponds en français. Sois utile, concret, et ne prétends jamais avoir utilisé un outil absent.", + ] + if extra_context: + parts.append(extra_context.strip()) + if history_text: + parts.append(history_text.strip()) + if intent_name == "playtest_code_task": + parts.append( + "Retourne uniquement un document HTML autonome complet. Un seul fichier. CSS inline. JS inline. Aucune dépendance externe." + ) + elif role == "code": + parts.append("Si tu proposes du code, reste précis et orienté exécution.") + parts.append(f"Message actuel de Sam:\n{msg.strip()}") + return "\n\n".join(part for part in parts if part) + + def _append_chat_stream_entry(self, msg: str, response: str, meta: dict): + try: + entry = {"ts": time.time(), "msg": msg, "response": response, "meta": meta} + _append_jsonl(CHAT_STREAM_FILE, entry) + except Exception as exc: + print(f"[chat stream] {exc}", flush=True) + + def _build_selection_reason(self, backend: str, route: str, complexity: str, priors_used: bool, internal_observed: bool) -> str: + reason = f"route={route}, complexity={complexity}, backend={backend}" + if priors_used: + reason += ", configured_model_priors used" + if internal_observed: + reason += ", internal_observed_data available" + return reason + + def _maybe_log_episodic(self, msg: str, response: str, meta: dict): + 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_memory as _cm + if response and not response.startswith("Erreur contrôlée"): + _cm.log_episodic(msg, response, meta) + except Exception as exc: + print(f"[chat memory log] {exc}", flush=True) + + def _generate_playtest(self, msg: str, history_text: str, req_id: str) -> dict: + _chat_stage(req_id, "Playtest builder", "generation HTML autonome + sauvegarde locale") + prompt = self._build_chat_payload( + msg, + "playtest_code_task", + "code", + history_text, + extra_context=( + "Objectif: construire une mini-app web visible immédiatement dans le Playtest Cortex local. " + "N'inclus ni explication ni markdown autour du HTML." + ), + ) + html, llm_error = self._call_opencode_chat(prompt, timeout_s=40) + html_doc = _extract_html_document(html) + used_fallback = False + if not html_doc: + used_fallback = True + html_doc = _fallback_playtest_html(msg) + file_path, playtest_url = _write_playtest_file(html_doc) + response = f"Fichier créé: {file_path.as_posix()} URL Playtest: {playtest_url}" + meta = { + "intent": "playtest_code_task", + "complexity": "hard", + "routing_decision": "playtest_builder_direct", + "router_used": False, + "judge_used": False, + "selected_backend": "playtest_builder", + "selection_reason": "playtest code request bypassed route_v2 and used local HTML builder", + "backend": "playtest_builder", + "tools_used": ["file_write"], + "confidence": "high" if not used_fallback else "medium", + "evidence_count": 1, + "playtest_path": str(file_path.relative_to(Path(r"H:\Code\Paperclip"))).replace("\\", "/"), + "playtest_url": playtest_url, + "auto_open_playtest": True, + "route_reason": "generated_playtest_html", + "history_used": bool(history_text), + "history_count": history_text.count("Sam:"), + "benchmark_basis": { + "internal_observed": False, + "configured_priors": True, + "official_sources": [], + }, + } + if llm_error: + meta["llm_error"] = llm_error + return {"response": response, "meta": meta, "req_id": req_id} + + def _handle_api_chat(self): + import urllib.request as _ur + 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") + + length = _safe_int(self.headers.get("Content-Length", "0")) + raw_body = self.rfile.read(length).decode("utf-8-sig") if length > 0 else "{}" + body = json.loads(raw_body or "{}") + msg = (body.get("message") or "").strip() + req_id = body.get("req_id") or f"r{int(time.time()*1000)}" + if not msg: + return self._controlled_chat_error("message vide", "simple_chat", req_id) + + _chat_stage(req_id, "Réception", "parse + intent") + try: + import cortex_intent as _ci + intent = _ci.detect_intent(msg) + except Exception as exc: + print(f"[chat intent] {exc}", flush=True) + intent = {"intent": "simple_chat", "confidence": "medium", "route_reason": "intent_fallback"} + intent_name = intent.get("intent") if isinstance(intent, dict) else getattr(intent, "intent", "simple_chat") + confidence = intent.get("confidence") if isinstance(intent, dict) else getattr(intent, "confidence", "medium") + complexity = _infer_complexity(msg, intent_name) + history_turns = _read_recent_history(max_turns=4, include_responses=not _is_playtest_code_request(msg)) + history_text, history_count = _history_prompt(history_turns, for_code=_is_playtest_code_request(msg)) + history_used = bool(history_text) + role = "code" if msg.lower().startswith("/code") or any(token in msg.lower() for token in ["python", "git", "serve.py", "router"]) else "general" + base_meta = { + "intent": intent_name, + "complexity": complexity, + "tools_used": [], + "confidence": confidence, + "history_used": history_used, + "history_count": history_count, + "benchmark_basis": { + "internal_observed": False, + "configured_priors": True, + "official_sources": [], + }, + "req_id": req_id, + } + + if intent_name == "recent_web_search": + payload = { + "response": "Je dois lancer une recherche web réelle avant de répondre.", + "meta": { + **base_meta, + "backend": "direct_guardrail", + "routing_decision": "guardrail_recent_web_search", + "router_used": False, + "judge_used": False, + "selected_backend": "direct_guardrail", + "selection_reason": "recent_web_search requires a real web tool first", + "route_reason": "needs_web_search", + "needs_web_search": True, + "needs_vault_search": False, + }, + "req_id": req_id, + } + self._append_chat_stream_entry(msg, payload["response"], payload["meta"]) + return payload + + if intent_name in ("local_project_search", "vault_memory_search"): + payload = { + "response": "Je dois d'abord chercher dans le vault, la mémoire ou les fichiers locaux avant d'affirmer quelque chose sur ce projet.", + "meta": { + **base_meta, + "backend": "direct_guardrail", + "routing_decision": "guardrail_local_project_search", + "router_used": False, + "judge_used": False, + "selected_backend": "direct_guardrail", + "selection_reason": "local project claims require real evidence first", + "route_reason": "needs_vault_or_file_search", + "needs_web_search": False, + "needs_vault_search": True, + }, + "req_id": req_id, + } + self._append_chat_stream_entry(msg, payload["response"], payload["meta"]) + return payload + + if intent_name == "playtest_dashboard_help": + payload = { + "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." + ), + "meta": { + **base_meta, + "backend": "direct_guardrail", + "routing_decision": "direct_playtest_help", + "router_used": False, + "judge_used": False, + "selected_backend": "direct_guardrail", + "selection_reason": "safe dashboard help answer", + "route_reason": "dashboard_context_direct", + "needs_web_search": False, + "needs_vault_search": False, + }, + "req_id": req_id, + } + self._append_chat_stream_entry(msg, payload["response"], payload["meta"]) + return payload + + if intent_name == "identity": + payload = { + "response": "Je suis Cortex, l’assistant cognitif local de Sam pour le projet Paperclip.", + "meta": { + **base_meta, + "backend": "direct_guardrail", + "routing_decision": "direct_identity", + "router_used": False, + "judge_used": False, + "selected_backend": "direct_guardrail", + "selection_reason": "safe identity answer", + "route_reason": "identity_direct", + "needs_web_search": False, + "needs_vault_search": False, + }, + "req_id": req_id, + } + self._append_chat_stream_entry(msg, payload["response"], payload["meta"]) + return payload + + if _is_playtest_code_request(msg): + payload = self._generate_playtest(msg, history_text, req_id) + self._append_chat_stream_entry(msg, payload["response"], payload["meta"]) + return payload + + prompt = self._build_chat_payload(msg, intent_name, role, history_text) + start_ts = time.time() + response = "" + backend = "" + route_reason = "" + routing_decision = "" + router_used = False + judge_used = False + selected_backend = "" + selection_reason = "" + status = "ok" + scores = None + internal_observed = False + + if _is_simple_fact_question(msg) or complexity == "simple": + _chat_stage(req_id, "Réponse rapide", "minimax_fast direct") + response, fast_err = self._call_opencode_chat(prompt, timeout_s=30) + backend = "minimax_fast" + route_reason = "fast_direct_simple" + routing_decision = "fast_minimax_direct" + router_used = False + judge_used = False + selected_backend = backend + if fast_err: + status = "timeout" if fast_err == "timeout" else ("empty" if fast_err == "empty_response" else "error") + response = "Je n’ai pas obtenu de réponse rapide fiable, donc je passe sur un fallback contrôlé." + backend = "error_guard" + selected_backend = backend + selection_reason = f"fast direct failed: {fast_err}" + else: + selection_reason = self._build_selection_reason(backend, route_reason, complexity, True, False) + else: + _chat_stage(req_id, "Router v2", "route_v2 avec timeout strict") + router_used = True + try: + payload = json.dumps({"text": prompt, "role": role}).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=70) as reply: + router_data = json.loads(reply.read().decode()) + response = (router_data.get("response") or "").strip() + backend = router_data.get("backend") or "router_unknown" + selected_backend = backend + route_reason = router_data.get("v2_path") or "route_v2" + routing_decision = route_reason + judge_used = route_reason in ("judge_pass", "consensus") + scores = router_data.get("all_scores") + internal_observed = True + if not response: + status = "empty" + fallback_response, fallback_err = self._call_opencode_chat(prompt, timeout_s=35) + if fallback_response: + response = fallback_response + backend = "minimax_fast" + selected_backend = backend + route_reason = "route_v2_empty_then_fast_fallback" + routing_decision = "router_empty_fallback_fast" + selection_reason = "route_v2 empty response, fell back to minimax_fast" + status = "ok" + else: + response = "Le routeur n’a pas produit de contenu exploitable. Je renvoie un fallback contrôlé au lieu de couper la connexion." + backend = "error_guard" + selected_backend = backend + selection_reason = self._build_selection_reason(selected_backend, route_reason, complexity, True, internal_observed) + except Exception as exc: + err_text = "timeout" if "timed out" in str(exc).lower() else str(exc) + status = "timeout" if "timeout" in err_text.lower() else "error" + fallback_response, fallback_err = self._call_opencode_chat(prompt, timeout_s=35) + if fallback_response: + response = fallback_response + backend = "minimax_fast" + selected_backend = backend + route_reason = "route_v2_error_then_fast_fallback" + routing_decision = "router_timeout_fallback" if status == "timeout" else "router_error_fallback" + selection_reason = f"router failure captured ({err_text}); minimax_fast fallback used" + status = "ok" + else: + response = f"Le routeur est indisponible ou trop lent ({err_text})." + backend = "error_guard" + selected_backend = backend + route_reason = "route_v2_error" + routing_decision = "router_timeout_fallback" if status == "timeout" else "router_error_fallback" + selection_reason = f"router failure captured: {err_text}" + + latency_s = time.time() - start_ts + meta = { + **base_meta, + "backend": backend, + "routing_decision": routing_decision, + "router_used": router_used, + "judge_used": judge_used, + "selected_backend": selected_backend or backend, + "selection_reason": selection_reason or self._build_selection_reason(selected_backend or backend, route_reason or routing_decision, complexity, True, internal_observed), + "route_reason": route_reason or routing_decision, + "needs_web_search": False, + "needs_vault_search": False, + "selected_backend_latency_s": round(latency_s, 3), + "benchmark_basis": { + "internal_observed": internal_observed, + "configured_priors": True, + "official_sources": [], + }, + "role": role, + } + if scores: + meta["scores"] = scores + try: + judge_score = max(_safe_float(v) for v in scores.values()) if isinstance(scores, dict) and scores else None + except Exception: + judge_score = None + else: + judge_score = None + + if backend == "error_guard": + meta["error"] = response + payload = self._controlled_chat_error(response, intent_name, req_id, extra_meta=meta) + self._append_chat_stream_entry(msg, payload["response"], payload["meta"]) + return payload + + _update_router_benchmarks( + selected_backend or backend, + latency_s=latency_s, + status=status, + domains=[intent_name, role, "playtest_html" if intent_name == "playtest_code_task" else ""], + judge_score=judge_score, + ) + self._maybe_log_episodic(msg, response, meta) + self._append_chat_stream_entry(msg, response, meta) + return {"response": response or "Erreur contrôlée: réponse vide", "meta": meta, "req_id": req_id} + def do_GET(self): parsed = urlparse(self.path) if parsed.path == "/" or parsed.path == "/index.html": @@ -471,6 +1251,14 @@ class Handler(http.server.SimpleHTTPRequestHandler): if parsed.path == "/gpu": self._serve_static(HERE / "brain_gpu.html", "text/html; charset=utf-8") return + if parsed.path.startswith("/playtests/"): + name = parsed.path.split("/playtests/", 1)[-1] + target = _playtest_file_from_name(name) + if not target or not target.exists(): + self._safe_send_error(404, "playtest not found") + return + self._send_text_file(target, "text/html; charset=utf-8") + return if parsed.path == "/api/devices": try: import pyaudio @@ -1773,6 +2561,23 @@ class Handler(http.server.SimpleHTTPRequestHandler): import subprocess as _sp from urllib.parse import urlparse as _up parsed = _up(self.path) + if parsed.path == "/api/chat": + req_id = "" + try: + payload = self._handle_api_chat() + except Exception as exc: + try: + _chat_stage_done(req_id) + except Exception: + pass + print(f"[api/chat guarded] {type(exc).__name__}: {exc}", flush=True) + payload = self._controlled_chat_error(str(exc), "simple_chat", req_id) + self._send_json(payload) + try: + _chat_stage_done(payload.get("req_id", "")) + except Exception: + pass + return if parsed.path == "/api/calibrate": # Tuer voice_input et attendre libération du mic (VAULT / ".voice-calibrating.flag").touch()