stabilize cortex chat playtest and cognitive ui
This commit is contained in:
parent
f1dee2aa38
commit
5f723eb3ef
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,'<') + '</code>';
|
||||
if (info) info.innerHTML = 'Chargement : <code style="color:#cdf">' + target.replace(/</g,'<') + '</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,'<') + '</code>';
|
||||
} catch (e) {
|
||||
if (info) info.innerHTML = 'Échec Playtest : <code style="color:#ffb49a">' + String(e.message || e).replace(/</g,'<') + '</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,'<')}</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 => ({'&':'&','<':'<','>':'>'}[c]))}</div>
|
||||
<div style="color:#a8b7da;font-size:10px;line-height:1.5;margin-top:4px">${(responses[model] || 'empty_response').replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c]))}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
const question = (round.question || '').replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[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, '&')
|
||||
.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 =>
|
||||
`<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 => ({'&':'&','<':'<','>':'>'}[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'a touché. Plus la valeur est haute, plus il vient d'être réactivé.</div>`;
|
||||
return `<div data-tip="${tip.replace(/"/g,'"')}" 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,'"')}" 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'est la mémoire long terme.</div>`;
|
||||
return `<div data-tip="${tip.replace(/"/g,'"')}" 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,'"')}" 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'apprentissage est en pause (augmente l'activité ou attends la pensée vagabonde).</div>`;
|
||||
return `<div data-tip="${tip.replace(/"/g,'"')}" 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,'"')}" 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é
|
||||
|
|
|
|||
|
|
@ -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 "<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('&', '&').replace('<', '<').replace('>', '>')}</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, 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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue