Files
brain/docs/observer/dashboard.js
T

238 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { parseEpisodes, filterEpisodes, attributeNodes, groupBySession, aggregate, inferConflicts } from './dashboard-core.js';
const AGD = window.AGD;
let episodes = [];
let skipped = 0;
let network = null;
// ── data loading ──────────────────────────────────────────────
async function loadEpisodes() {
const files = await fetch('/api/episodes').then((r) => r.json());
const all = [];
let skip = 0;
for (const f of files) {
const url = '/docs/observer/' + f;
const text = await fetch(url).then((r) => (r.ok ? r.text() : ''));
const r = parseEpisodes(text);
all.push(...r.episodes);
skip += r.skipped;
}
all.sort((a, b) => String(a.startedAt).localeCompare(String(b.startedAt)));
episodes = all;
skipped = skip;
document.getElementById('status').textContent =
`${episodes.length} эпизодов · ${skipped} пропущено`;
}
// ── graph banner ──────────────────────────────────────────────
function renderGraph() {
const nodes = new vis.DataSet(AGD.NODES);
const edges = new vis.DataSet(AGD.EDGES);
network = new vis.Network(
document.getElementById('network'),
{ nodes, edges },
{
groups: AGD.GROUPS,
nodes: { shape: 'dot', borderWidth: 2, font: { multi: 'html' } },
edges: { smooth: { type: 'continuous', roundness: 0.5 } },
physics: { enabled: false },
interaction: { hover: true, tooltipDelay: 400 },
}
);
network.once('afterDrawing', () => network.fit());
return { nodes, edges };
}
// ── view switching ────────────────────────────────────────────
const views = {};
let activeView = 'map';
views.map = function renderMapView() {
// Plain mode: clear any overlay coloring applied by other views.
window.__graph.nodes.update(AGD.NODES.map((n) => ({ id: n.id, color: undefined })));
// List the design-time conflict edges (dashed edges carry an emoji label).
const conflicts = AGD.EDGES.filter((e) => e.dashes === true);
const ul = document.getElementById('map-conflicts');
ul.innerHTML = '';
for (const c of conflicts) {
const li = document.createElement('li');
li.textContent = `${c.label || '•'} ${c.from}${c.to}: ${c.title || ''}`;
ul.appendChild(li);
}
};
views.replay = function renderReplayView() {
const filter = {
classification: document.getElementById('f-classification').value || undefined,
outcome: document.getElementById('f-outcome').value || undefined,
withErrors: document.getElementById('f-errors').checked || undefined,
};
const list = filterEpisodes(episodes, filter);
const ul = document.getElementById('replay-episodes');
ul.innerHTML = '';
list.forEach((ep) => {
const li = document.createElement('li');
li.textContent = `${ep.startedAt} · ${ep.taskClassification || '—'} · ${ep.outcome}`
+ (ep.errorCount ? ` · ⚠${ep.errorCount}` : '');
li.addEventListener('click', () => selectEpisode(ep));
ul.appendChild(li);
});
};
function selectEpisode(ep) {
const attr = attributeNodes(ep);
window.__graph.nodes.update(
AGD.NODES.map((n) => ({
id: n.id,
color: attr.nodeIds.includes(n.id)
? { background: '#268bd2', border: '#93a1a1' }
: { background: '#2a2a3a', border: '#444' },
}))
);
const d = document.getElementById('replay-detail');
const prov = ep.decisionProvenance;
const provLine = prov && prov.kind === 'user_directed_method'
? `перенаправление: выбран ${prov.node || '?'}, автономно был бы ${prov.claude_would_have_chosen || '?'}`
: prov ? prov.kind : '—';
const env = ep.environment || {};
d.innerHTML = `
<h3>${ep.taskClassification || '—'} · ${ep.pathType || '—'} · ${ep.outcome}</h3>
<p>provenance: ${provLine}</p>
<p>hard-floor: ${ep.hardFloor.invoked ? (ep.hardFloor.rules || []).join(', ') : 'нет'}</p>
<p>окружение: economy=${env.economy_level ?? '—'} · ${env.model || '—'} · turn ${env.session_turn ?? '—'}${env.post_compaction ? ' · post-compaction' : ''}${env.parallel_session ? ' · parallel' : ''}</p>
<p>атрибутировано узлов: ${attr.attributed} из ${attr.signals} сигналов</p>
<h4>События</h4>
<ol>${ep.events.map((e) => `<li>${eventLine(e)}</li>`).join('')}</ol>`;
}
views.feed = function renderFeedView() {
const groups = groupBySession(episodes);
const root = document.getElementById('feed-stream');
root.innerHTML = groups.map((g) => `
<section class="feed-group">
<h4>сессия ${g.taskRef.slice(0, 8)} · ${g.episodes.length} ходов</h4>
${g.episodes.map(feedCard).join('')}
</section>`).join('');
};
views.aggregate = function renderAggregateView() {
const a = aggregate(episodes);
applyHeat(a.nodeHeat);
const dist = (obj) => Object.entries(obj).map(([k, v]) => `${k}: ${v}`).join(' · ') || '—';
const topNodes = Object.entries(a.nodeHeat).sort((x, y) => y[1] - x[1]).slice(0, 10);
document.getElementById('agg-tiles').innerHTML = `
<div class="tile"><h4>Эпизодов</h4><p>${a.count}</p></div>
<div class="tile"><h4>Ошибки / ретраи</h4><p>${a.totalErrors} / ${a.totalRetries}</p></div>
<div class="tile"><h4>Доля перенаправлений</h4><p>${(a.redirectRate * 100).toFixed(0)}%</p></div>
<div class="tile"><h4>path_type</h4><p>${dist(a.pathType)}</p></div>
<div class="tile"><h4>outcome</h4><p>${dist(a.outcome)}</p></div>
<div class="tile"><h4>классы задач</h4><p>${dist(a.classification)}</p></div>
<div class="tile"><h4>economy-уровни</h4><p>${dist(a.economy)}</p></div>
<div class="tile"><h4>Топ узлов</h4><p>${topNodes.map(([k, v]) => `${k}×${v}`).join(' · ') || '—'}</p></div>`;
const c = inferConflicts(episodes, AGD.EDGES);
const top = (obj) => Object.entries(obj).sort((x, y) => y[1] - x[1]).map(([k, v]) => `${k}×${v}`).join(' · ') || '—';
document.getElementById('agg-conflicts').innerHTML = `
<h4>Конфликты — три слоя</h4>
<p><b>Дизайн-конфликты (факт):</b> ${c.design.length} размеченных рёбер</p>
<p><b>Трение (инференс):</b> ${top(c.friction)}</p>
<p><b>Корреляция (эвристика):</b> ${c.correlation.length} ходов с ошибкой на паре конфликтующих узлов</p>`;
};
function applyHeat(nodeHeat) {
const max = Math.max(1, ...Object.values(nodeHeat));
window.__graph.nodes.update(
AGD.NODES.map((n) => {
const h = nodeHeat[n.id] || 0;
const t = h / max;
return {
id: n.id,
color: h
? { background: `rgba(38,139,210,${0.25 + 0.6 * t})`, border: '#93a1a1' }
: { background: '#2a2a3a', border: '#444' },
};
})
);
}
function feedCard(ep) {
const dur = ep.durationMs != null ? Math.round(ep.durationMs / 1000) + 's' : '—';
const redirect = ep.decisionProvenance && ep.decisionProvenance.kind === 'user_directed_method' ? ' ↪' : '';
return `<div class="feed-card">
${ep.startedAt} · ${ep.taskClassification || '—'} · ${ep.pathType || '—'} · ${ep.nodeChosen || '—'}
· ${dur}${ep.errorCount ? ' · ⚠' + ep.errorCount : ''}${ep.retryCount ? ' · ↻' + ep.retryCount : ''}${redirect}
</div>`;
}
function eventLine(e) {
switch (e.kind) {
case 'skill_invoked': return `skill: ${e.skill}`;
case 'error': return `error: ${e.message || ''}`;
case 'retry': return 'retry';
case 'interrupt': return 'interrupt';
case 'hook_fired': return `hooks (${Object.keys(e.counts || {}).length} типов, errors ${e.errors || 0})`;
case 'tool_summary': return `инструменты: ${Object.entries(e.counts || {}).map(([k, v]) => `${k}×${v}`).join(', ')}`;
case 'time_burn': return `time_burn: ${e.duration_ms} ms`;
case 'parse_gap': return `parse_gap: ${e.broken}/${e.total}`;
default: return e.kind;
}
}
function switchView(name) {
activeView = name;
for (const v of ['map', 'replay', 'feed', 'aggregate']) {
document.getElementById('view-' + v).style.display = v === name ? 'block' : 'none';
}
document.querySelectorAll('#tabbar button').forEach((b) => {
b.classList.toggle('active', b.dataset.view === name);
});
if (views[name]) views[name]();
if (name === 'feed') startPolling(); else stopPolling();
}
// ── boot ──────────────────────────────────────────────────────
async function boot() {
const gds = renderGraph();
window.__graph = { network, ...gds };
document.querySelectorAll('#tabbar button').forEach((b) => {
b.addEventListener('click', () => switchView(b.dataset.view));
});
['f-classification', 'f-outcome', 'f-errors'].forEach((id) => {
document.getElementById(id).addEventListener('change', () => {
if (activeView === 'replay') views.replay();
});
});
document.getElementById('feed-pause').addEventListener('click', () => {
if (pollTimer) stopPolling(); else startPolling();
});
await loadEpisodes();
switchView('map');
}
// ── live polling for the Лента view ───────────────────────────
const POLL_MS = 5000;
let pollTimer = null;
async function pollTick() {
const before = episodes.length;
await loadEpisodes();
if (episodes.length !== before && activeView === 'feed') views.feed();
}
function startPolling() {
if (pollTimer) return;
pollTimer = setInterval(pollTick, POLL_MS);
const el = document.getElementById('feed-poll-state');
if (el) el.textContent = `автоопрос каждые ${POLL_MS / 1000}s`;
}
function stopPolling() {
clearInterval(pollTimer);
pollTimer = null;
const el = document.getElementById('feed-poll-state');
if (el) el.textContent = 'опрос на паузе';
}
export function getEpisodes() { return episodes; }
export { views, switchView };
boot();