stabilize cortex chat playtest and cognitive ui

This commit is contained in:
WarframeBuilder 2026-05-01 13:08:44 +02:00
parent f1dee2aa38
commit 5f723eb3ef
3 changed files with 1022 additions and 32 deletions

View File

@ -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",

View File

@ -171,14 +171,14 @@
<!-- Top activations -->
<div data-help="cb-activations" style="color:#aab;font-size:9px;text-transform:uppercase;letter-spacing:1px;margin-top:10px;margin-bottom:5px;cursor:help;display:flex;justify-content:space-between;align-items:center">
<span>Activation neurale <span style="color:#55ff99;text-transform:none;letter-spacing:0">réel</span></span>
<span>Activation neurale récente <span style="color:#55ff99;text-transform:none;letter-spacing:0">réel</span></span>
<span id="hb-act-info" style="color:#557;font-size:8.5px;text-transform:none;letter-spacing:0;font-family:ui-monospace,monospace"></span>
</div>
<div id="cb-activations" data-help="cb-activations" style="display:flex;flex-direction:column;gap:3px;font-size:10px"></div>
<!-- Apprentissages Hebbian -->
<div data-help="cb-hebbian" style="color:#aab;font-size:9px;text-transform:uppercase;letter-spacing:1px;margin-top:13px;margin-bottom:5px;cursor:help;display:flex;justify-content:space-between;align-items:center">
<span>Apprentissages Hebbian <span style="color:#55ff99;text-transform:none;letter-spacing:0">réel disque</span></span>
<span>Traces Hebbian récentes <span style="color:#55ff99;text-transform:none;letter-spacing:0">réel disque</span></span>
<span id="hb-heb-info" style="color:#557;font-size:8.5px;text-transform:none;letter-spacing:0;font-family:ui-monospace,monospace"></span>
</div>
<div id="cb-hebbian" data-help="cb-hebbian" style="display:flex;flex-direction:column;gap:3px;font-size:10px"></div>
@ -196,7 +196,7 @@
<div id="cb-focus" data-help="cb-focus" style="font-size:10px;color:#778"></div>
<!-- World model : concepts qui grandissent (croissance Hebbian + activation cumulée) -->
<div data-help="cb-worldmodel" style="color:#aab;font-size:9px;text-transform:uppercase;letter-spacing:1px;margin-top:13px;margin-bottom:5px;cursor:help">Concepts qui grandissent <span style="color:#55ff99;text-transform:none;letter-spacing:0">world model</span></div>
<div data-help="cb-worldmodel" style="color:#aab;font-size:9px;text-transform:uppercase;letter-spacing:1px;margin-top:13px;margin-bottom:5px;cursor:help">Concepts consolidés / en croissance <span style="color:#55ff99;text-transform:none;letter-spacing:0">world model</span></div>
<div id="cb-worldmodel" data-help="cb-worldmodel" style="display:flex;flex-direction:column;gap:3px;font-size:10px;color:#778"></div>
<!-- Dernière décision autonome -->
@ -393,6 +393,12 @@
<div id="consortium-live-path" style="color:#6a7a9c;font-size:10px;margin-top:3px">Le prochain arbitrage apparaîtra ici</div>
<div id="consortium-live-note" style="color:#90a4cf;font-size:10px;line-height:1.45;margin-top:7px">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.</div>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button onclick="window._consortiumExplainWhy()" style="background:#0b1225;border:1px solid #27406f;color:#9fc0ff;cursor:pointer;font-size:10px;padding:5px 8px;border-radius:4px;font-family:inherit">Pourquoi ce modèle ?</button>
<button id="consortium-toggle-empty" onclick="window._toggleConsortiumEmpty()" style="background:#10172a;border:1px solid #2a3555;color:#9aaad0;cursor:pointer;font-size:10px;padding:5px 8px;border-radius:4px;font-family:inherit">masquer réponses vides</button>
<button id="consortium-toggle-last" onclick="window._toggleConsortiumLastOnly()" style="background:#10172a;border:1px solid #2a3555;color:#9aaad0;cursor:pointer;font-size:10px;padding:5px 8px;border-radius:4px;font-family:inherit">dernier round seulement</button>
<button onclick="window._consortiumShowJson()" style="background:#10172a;border:1px solid #2a3555;color:#9aaad0;cursor:pointer;font-size:10px;padding:5px 8px;border-radius:4px;font-family:inherit">voir JSON</button>
</div>
<div id="consortium-ranking" style="display:flex;flex-wrap:wrap;gap:6px"></div>
</div>
<div id="consortium-rounds" style="flex:1;overflow:auto;padding:10px 12px;display:flex;flex-direction:column;gap:10px"></div>
@ -421,6 +427,19 @@
<button onclick="openInObsidian()" style="width:100%;background:#06060e;border:1px solid #181828;color:#334;padding:6px;border-radius:4px;cursor:pointer;font-size:10px;font-family:inherit">Ouvrir dans Obsidian ↗</button>
</div>
</div>
<div id="brain-detail-modal" style="display:none;position:fixed;inset:0;background:rgba(2,6,16,.8);z-index:9998;align-items:center;justify-content:center;padding:24px">
<div style="width:min(760px,100%);max-height:88vh;overflow:auto;background:#07101f;border:1px solid #22345c;border-radius:14px;box-shadow:0 30px 90px rgba(0,0,0,.45);padding:18px 20px;font-family:ui-monospace,monospace">
<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start">
<div>
<div id="brain-detail-title" style="color:#e8efff;font-size:14px;font-weight:700;word-break:break-word">Détail</div>
<div id="brain-detail-subtitle" style="color:#8ea8dd;font-size:10px;margin-top:4px">source</div>
</div>
<button onclick="window._closeBrainDetail()" style="background:none;border:0;color:#6f84b8;font-size:18px;cursor:pointer">×</button>
</div>
<div id="brain-detail-meta" style="display:flex;flex-wrap:wrap;gap:6px;margin-top:12px"></div>
<div id="brain-detail-body" style="margin-top:14px;color:#b9c7e4;font-size:11px;line-height:1.6;white-space:pre-wrap"></div>
</div>
</div>
<div id="info">scroll=zoom · drag=rotate · GPU</div>
@ -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é : <code style="color:#cdf">' + target.replace(/</g,'&lt;') + '</code>';
if (info) info.innerHTML = 'Chargement : <code style="color:#cdf">' + target.replace(/</g,'&lt;') + '</code>';
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é : <code style="color:#cdf">' + target.replace(/</g,'&lt;') + '</code>';
} catch (e) {
if (info) info.innerHTML = 'Échec Playtest : <code style="color:#ffb49a">' + String(e.message || e).replace(/</g,'&lt;') + '</code>';
}
};
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é : <code style="color:#cdf">${target.replace(/</g,'&lt;')}</code>`;
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 = '<div class="consortium-card" style="color:#ffb49a">Impossible de lire le consortium pour le moment.</div>';
return;
}
ranking.innerHTML = (data.ranking || []).slice(0, 5).map((row, idx) =>
`<span class="consortium-chip"><b style="color:${idx === 0 ? '#67e6a8' : '#d2defd'}">${idx + 1}. ${row.model}</b><span>${row.win_rate}% · ${row.avg_latency_s}s</span></span>`
).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) =>
`<span class="consortium-chip"><b style="color:${idx === 0 ? '#67e6a8' : '#d2defd'}">${idx + 1}. ${row.model}</b><span>${row.win_rate}% · ${row.avg_latency_s}s</span></span>`
).join('')
: '<span class="consortium-chip" style="color:#8ea8dd">Pas assez de scores réels pour afficher un classement fiable.</span>';
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 `<div class="consortium-response${isWinner ? ' winner' : ''}">
<div style="display:flex;justify-content:space-between;gap:8px;align-items:center">
<div style="color:${isWinner ? '#bff7d7' : '#d0daf4'};font-size:10px;font-weight:700">${model}${isWinner ? ' · gagnant' : ''}</div>
<div style="color:${isWinner ? '#8de5b5' : '#7f8fb0'};font-size:9px">${why}</div>
<div style="color:${isWinner ? '#8de5b5' : statusColor};font-size:9px">${why}</div>
</div>
<div style="color:#a8b7da;font-size:10px;line-height:1.5;margin-top:4px">${(responses[model] || '(vide)').replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]))}</div>
<div style="color:#a8b7da;font-size:10px;line-height:1.5;margin-top:4px">${(responses[model] || 'empty_response').replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]))}</div>
</div>`;
}).join('');
const question = (round.question || '').replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
@ -2160,7 +2261,8 @@ window._renderConsortium = (data) => {
<div class="consortium-chip" style="color:#67e6a8;border-color:#24503c;background:rgba(20,42,30,.34)">winner ${round.winner || 'n/a'}</div>
</div>
<div style="color:#e1e9ff;font-size:11px;line-height:1.5;margin-top:7px">${question || '(question indisponible)'}</div>
<div style="margin-top:8px;display:flex;flex-direction:column;gap:8px">${rows}</div>
<div style="color:#7f92bc;font-size:9px;margin-top:6px">${round.route || round.v2_path || 'route non documentée'}</div>
<div style="margin-top:8px;display:flex;flex-direction:column;gap:8px">${rows || '<div style="color:#7f92bc">Tous les candidats ont été masqués par le filtre actuel.</div>'}</div>
</div>`;
}).join('') || '<div class="consortium-card" style="color:#7f92bc">Aucun round récent.</div>';
};
@ -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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
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 =>
`<span style="padding:4px 8px;border-radius:999px;border:1px solid ${ch.color || '#334'};background:${(ch.color || '#334')}22;color:${ch.color || '#ccd'};font-size:10px">${String(ch.label || '').replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]))}</span>`
).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() {
+ `<div style='color:#aac'>Activation : <b style='color:#88ff44'>${v.toFixed(3)}</b> (sur 1.0)</div>`
+ `<div style='color:#aac'>Demi-vie restante avant extinction : ~${Math.max(0, halfLife)} s</div>`
+ `<div style='color:#778;margin-top:4px;font-size:10px'>Pourquoi éveillé : un retrieve_context, un A* ou la boucle de pensée vagabonde l&#39;a touché. Plus la valeur est haute, plus il vient d&#39;être réactivé.</div>`;
return `<div data-tip="${tip.replace(/"/g,'&quot;')}" style="display:flex;align-items:center;gap:6px;cursor:help">
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 `<div data-tip="${tip.replace(/"/g,'&quot;')}" style="display:flex;align-items:center;gap:6px;cursor:help" data-brain-item="${detail}" onclick="window._showBrainSourceDetail(JSON.parse(this.dataset.brainItem))">
<div style="display:flex;align-items:center;gap:6px;cursor:pointer;width:100%">
<div style="flex:1;background:#04040c;border-radius:2px;height:14px;position:relative;overflow:hidden">
<div style="position:absolute;inset:0;width:${w}%;background:linear-gradient(90deg,#88ff44,#33ffcc);opacity:0.7"></div>
<span style="position:absolute;left:5px;top:0;line-height:14px;color:#cdf;font-size:9.5px">${_shortName(p)}</span>
</div>
<span style="color:#88ff44;font-size:9px;width:32px;text-align:right">${(v).toFixed(2)}</span>
</div>
</div>`;
}).join('');
}
@ -2346,7 +2506,8 @@ function refreshCerebralView() {
+ `<div style='color:#aac;margin-top:5px'>Force : <b style='color:#ffaa44'>${e.strength.toFixed(3)}</b></div>`
+ `<div style='color:#aac'>Soit ~<b>${coActivations}</b> co-activations cumulées (HEBBIAN_LR=0.01).</div>`
+ `<div style='color:#778;margin-top:4px;font-size:10px'>Plus on les pense ensemble, plus le lien grossit. Persisté sur disque, survit aux redémarrages. C&#39;est la mémoire long terme.</div>`;
return `<div data-tip="${tip.replace(/"/g,'&quot;')}" style="cursor:help">
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 `<div data-tip="${tip.replace(/"/g,'&quot;')}" style="cursor:help" data-brain-item="${detail}" onclick="window._showBrainSourceDetail(JSON.parse(this.dataset.brainItem))">
<div style="display:flex;justify-content:space-between;font-size:10px">
<span style="color:#cdf;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${_shortName(e.a)}</span>
<span style="color:#557;margin:0 5px"></span>
@ -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 = '<div style="color:#445;font-style:italic;font-size:10px">Apprentissage stagne — Cortex n\'a rien renforcé récemment</div>';
} else {
@ -2446,7 +2613,8 @@ function refreshCerebralView() {
+ `<div style='color:#aac;margin-top:4px'>Score de croissance : <b style='color:#33ffcc'>${score.toFixed(3)}</b></div>`
+ `<div style='color:#aac'>= activation cumulée + force des liens Hebbian.</div>`
+ `<div style='color:#778;margin-top:4px;font-size:10px'>Plus ce score grandit, plus Cortex consolide ce concept dans son world model. Si tous les scores sont bas → l&#39;apprentissage est en pause (augmente l&#39;activité ou attends la pensée vagabonde).</div>`;
return `<div data-tip="${tip.replace(/"/g,'&quot;')}" style="display:flex;align-items:center;gap:6px;cursor:help">
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 `<div data-tip="${tip.replace(/"/g,'&quot;')}" style="display:flex;align-items:center;gap:6px;cursor:help" data-brain-item="${detail}" onclick="window._showBrainSourceDetail(JSON.parse(this.dataset.brainItem))">
<div style="flex:1;background:#04040c;border-radius:2px;height:13px;position:relative;overflow:hidden">
<div style="position:absolute;inset:0;width:${w}%;background:linear-gradient(90deg,#33ffcc,#88ff44);opacity:0.55"></div>
<span style="position:absolute;left:5px;top:0;line-height:13px;color:#cdf;font-size:9.5px">${_shortName(p)}</span>
@ -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 = `<div style="color:#33ffcc;font-size:9px;margin-bottom:3px">action: ${last.action} · il y a ${_fmtAge(ageS)}</div>` +
`<div style="color:#aac;font-size:10px;line-height:1.4;white-space:pre-line">${(last.response||'').slice(0,300)}</div>`;
ce.innerHTML = `<div style="color:#33ffcc;font-size:9px;margin-bottom:3px">dernière action: ${last.action || 'n/a'} · il y a ${_fmtAge(ageS)}</div>` +
`<div style="color:#aac;font-size:10px;line-height:1.4;white-space:pre-line">${(last.response||'').slice(0,300) || 'résultat indisponible'}</div>`;
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é

View File

@ -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", "", "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 "<html" in candidate.lower() or "<!doctype html" in candidate.lower():
raw = candidate
break
lower = raw.lower()
start = lower.find("<!doctype html")
if start < 0:
start = lower.find("<html")
if start >= 0:
raw = raw[start:]
end = raw.lower().rfind("</html>")
if end >= 0:
raw = raw[:end + len("</html>")]
return raw.strip()
def _fallback_playtest_html(message: str) -> str:
title = "Playtest Cortex"
if "calculatrice" in (message or "").lower():
title = "Calculatrice Playtest"
body = """
<main class="shell">
<section class="card">
<h1>Calculatrice locale</h1>
<p>Fallback autonome généré par Cortex. Aucun CDN, aucun backend.</p>
<div class="screen" id="screen">0</div>
<div class="grid" id="keys"></div>
</section>
</main>
<script>
const screen = document.getElementById('screen');
const keys = document.getElementById('keys');
const layout = ['7','8','9','/','4','5','6','*','1','2','3','-','0','.','=','+','C'];
let expr = '';
function render(){ screen.textContent = expr || '0'; }
layout.forEach(key => {
const btn = document.createElement('button');
btn.textContent = key;
btn.className = /[\\/=*+-]/.test(key) ? 'op' : '';
btn.onclick = () => {
if (key === 'C') { expr = ''; return render(); }
if (key === '=') {
try { expr = String(Function('return (' + expr + ')')()); }
catch { expr = 'Erreur'; }
return render();
}
if (expr === 'Erreur') expr = '';
expr += key;
render();
};
keys.appendChild(btn);
});
render();
</script>
"""
else:
body = f"""
<main class="shell">
<section class="card">
<h1>{title}</h1>
<p>Fallback HTML autonome généré après un échec LLM.</p>
<textarea id="notes" placeholder="Décris ici ce que Sam veut voir...">{(message or '').replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')}</textarea>
<div class="actions">
<button id="save">Sauvegarder localement</button>
<button id="clear" class="ghost">Effacer</button>
</div>
<p id="status">Prêt.</p>
</section>
</main>
<script>
const key = 'cortex-playtest-notes';
const notes = document.getElementById('notes');
const status = document.getElementById('status');
notes.value = localStorage.getItem(key) || notes.value;
document.getElementById('save').onclick = () => {{
localStorage.setItem(key, notes.value);
status.textContent = 'Sauvegardé dans localStorage.';
}};
document.getElementById('clear').onclick = () => {{
notes.value = '';
localStorage.removeItem(key);
status.textContent = 'Effacé.';
}};
</script>
"""
return f"""<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>
:root {{ color-scheme: dark; --bg:#0b1020; --panel:#111936; --line:#26345f; --text:#eef3ff; --muted:#9eb1d9; --accent:#4fd1c5; --accent2:#f6ad55; }}
* {{ box-sizing:border-box; font-family: ui-sans-serif, system-ui, sans-serif; }}
body {{ margin:0; min-height:100vh; background:radial-gradient(circle at top, #1a254d, #070b16 60%); color:var(--text); }}
.shell {{ min-height:100vh; display:grid; place-items:center; padding:24px; }}
.card {{ width:min(520px, 100%); background:rgba(17,25,54,.88); border:1px solid var(--line); border-radius:22px; padding:24px; box-shadow:0 24px 80px rgba(0,0,0,.45); backdrop-filter: blur(10px); }}
h1 {{ margin:0 0 8px; font-size:clamp(1.6rem, 4vw, 2.4rem); }}
p {{ color:var(--muted); line-height:1.5; }}
.screen {{ margin:18px 0; padding:18px; background:#060913; border:1px solid #1c2748; border-radius:16px; font-size:2rem; text-align:right; min-height:76px; }}
.grid {{ display:grid; grid-template-columns:repeat(4, 1fr); gap:10px; }}
button {{ border:0; border-radius:14px; padding:14px; font-size:1rem; background:#182445; color:var(--text); cursor:pointer; }}
button.op {{ background:linear-gradient(135deg, var(--accent), #3182ce); color:#04111a; font-weight:700; }}
button.ghost {{ background:transparent; border:1px solid var(--line); color:var(--muted); }}
textarea {{ width:100%; min-height:220px; margin:16px 0; padding:14px; border-radius:16px; border:1px solid var(--line); background:#0a1124; color:var(--text); resize:vertical; }}
.actions {{ display:flex; gap:10px; }}
#status {{ margin-top:12px; color:var(--accent2); }}
</style>
</head>
<body>{body}
</body>
</html>"""
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, longlet Playtest, longlet 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, lassistant 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 nai 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 na 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()