feat(landing): add state API service with real-fit score drill-down

- Add apaw-state-api Flask service (landing/api/server.py) that serves
  agent fit scores, best models, and explanations from real-fit.db
- Add nginx proxy rule: /api/state → apaw-state-api:8080
- Add fit-score drill-down modal (click heatmap cell → score breakdown
  + explanation) in api.js, styles.css, and index.html
- Add real-fit-recalc.py script for offline score recalculation from
  stored SQLite responses
- Add real-fit-engine.py (evaluation engine) and sync-dashboard-data.py
- Add Dockerfile ENTRYPOINT + entrypoint.sh for landing container
- Add docker-compose.ollama.yml for local Ollama inference
- Update kilo.jsonc command models and agent-versions.json
- Regenerate index.standalone.html with latest dashboard data
- Add .gitignore entries for __pycache__, runtime data, and backups
This commit is contained in:
Deploy Bot
2026-05-27 19:53:40 +01:00
parent 954c739dc9
commit dbbf4c32e1
19 changed files with 3012 additions and 137 deletions

View File

@@ -739,6 +739,77 @@
.swap-vis { flex-direction: column; }
.swap-arrow { transform: rotate(90deg); }
}
/* Analytics Hierarchy */
.analytics-tree { font-size: 12.5px; }
.at-model { margin-bottom: 10px; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
.at-model-header {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; background: var(--bg-panel);
font-weight: 600; cursor: pointer; user-select: none; font-size: 13px;
}
.at-model-header::before { content: '▸'; font-size: 10px; transition: transform .2s; color: var(--accent-cyan); }
.at-model.open .at-model-header::before { transform: rotate(90deg); }
.at-model-body { display: none; padding: 6px 10px 8px 22px; }
.at-model.open .at-model-body { display: block; }
.at-cat { margin-bottom: 4px; }
.at-cat-header {
display: flex; align-items: center; gap: 6px;
padding: 4px 8px; border-radius: 6px; cursor: pointer;
color: var(--text-secondary); font-size: 11.5px;
}
.at-cat-header:hover { background: var(--bg-card-hover); }
.at-cat-header::before { content: '▸'; font-size: 9px; transition: transform .2s; }
.at-cat.open .at-cat-header::before { transform: rotate(90deg); }
.at-cat-body { display: none; padding: 3px 0 2px 14px; }
.at-cat.open .at-cat-body { display: block; }
.at-agent {
display: flex; align-items: center; gap: 8px;
padding: 2px 8px; border-radius: 4px; font-size: 11.5px; color: var(--text-primary);
}
.at-agent-badge {
font-size: 10px; font-weight: 700; padding: 1px 6px;
border-radius: 10px; background: var(--bg-card-hover); color: var(--accent-green);
margin-left: auto; flex-shrink: 0;
}
.analytics-bars { display: flex; flex-direction: column; gap: 10px; }
.ab-row { display: flex; align-items: center; gap: 12px; }
.ab-label { width: 80px; font-size: 12px; font-weight: 600; color: var(--text-primary); flex-shrink: 0; }
.ab-track { flex: 1; height: 18px; background: var(--bg-panel); border-radius: 9px; overflow: hidden; }
.ab-fill { height: 100%; border-radius: 9px; background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green)); transition: width .6s ease; min-width: 3px; }
.ab-count { width: 28px; font-size: 12px; font-weight: 700; text-align: right; color: var(--text-secondary); }
.analytics-heatmap {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
gap: 3px;
}
.ah-cell {
aspect-ratio: 1; border-radius: 6px;
display: flex; flex-direction: column; align-items: center; justify-content: center;
font-size: 9.5px; font-weight: 600; cursor: pointer; transition: transform .12s; position: relative;
}
.ah-cell:hover { transform: scale(1.07); z-index: 1; }
.ah-cell-name { font-size: 8.5px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; width: 90%; text-align: center; }
.ah-cell-score { font-size: 10.5px; margin-top: 1px; }
.ah-tip {
position: absolute; bottom: calc(100% + 4px); left: 50%; transform: translateX(-50%);
background: var(--bg-panel); border: 1px solid var(--border-bright); border-radius: 6px;
padding: 4px 8px; font-size: 10px; white-space: nowrap; pointer-events: none;
opacity: 0; transition: opacity .12s; z-index: 10;
}
.ah-cell:hover .ah-tip { opacity: 1; }
.commands-matrix-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.commands-matrix-table thead th { text-align: left; padding: 8px 10px; font-weight: 600; color: var(--text-secondary); border-bottom: 1px solid var(--border); font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; }
.commands-matrix-table tbody td { padding: 6px 10px; border-bottom: 1px solid var(--border); color: var(--text-primary); }
.commands-matrix-table tbody tr:last-child td { border-bottom: none; }
.commands-matrix-table tbody tr:hover td { background: var(--bg-card-hover); }
.cm-score { font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: 8px; }
.cm-score.good { background: rgba(0,255,148,0.12); color: var(--accent-green); }
.cm-score.ok { background: rgba(250,204,21,0.12); color: var(--accent-yellow); }
.cm-score.warn{ background: rgba(255,71,87,0.12); color: var(--accent-red); }
</style>
</head>
<body>
@@ -760,6 +831,7 @@
<button class="tab-btn" onclick="switchTab('agents', this)">All Agents</button>
<button class="tab-btn" onclick="switchTab('history', this)">Timeline</button>
<button class="tab-btn" onclick="switchTab('recommendations', this)">Recommendations</button>
<button class="tab-btn" onclick="switchTab('analytics', this)">Analytics</button>
<button class="tab-btn" onclick="switchTab('heatmap', this)">Heatmap</button>
<button class="tab-btn" onclick="switchTab('impact', this)">Impact</button>
</div>
@@ -829,6 +901,46 @@
<div class="agents-grid" id="allRecommendations"></div>
</div>
<!-- Analytics Tab -->
<div id="tab-analytics" class="tab-panel">
<div class="stats-row" id="analyticsStats"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:24px">
<div class="chart-wrap">
<div class="chart-title">Model Distribution</div>
<div class="chart-sub">Агенты по LLM-моделям (иерархия: модель → категория)</div>
<div id="modelHierarchyTree" style="padding:16px;font-size:13px;max-height:420px;overflow-y:auto;"></div>
</div>
<div class="chart-wrap">
<div class="chart-title">Category Breakdown</div>
<div class="chart-sub">Дистрибуция по категориям и ролям</div>
<div id="categoryBreakdownBars" style="padding:16px;"></div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1.5fr;gap:20px;margin-bottom:24px">
<div class="chart-wrap">
<div class="chart-title">Fit Score Distribution</div>
<div class="chart-sub">Тепловая карта fit-score по агентам</div>
<div id="fitScoreHeatmap" style="padding:16px;"></div>
</div>
<div class="chart-wrap">
<div class="chart-title">Commands Matrix</div>
<div class="chart-sub">Команды и их модели из реальных конфигов</div>
<div style="overflow-x:auto;padding:8px;">
<table class="matrix-table" id="commandsMatrixTable">
<thead>
<tr><th>Команда</th><th>Модель</th><th>Score</th><th>Описание</th></tr>
</thead>
<tbody>
<!-- Dynamic -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Heatmap Tab -->
<div id="tab-heatmap" class="tab-panel">
<div class="hm-wrap">
@@ -1055,7 +1167,7 @@ const MODEL_BENCHMARKS = {
// Default embedded data (minimal - updated by sync script)
const EMBEDDED_DATA = {
"version": "1.0.0",
"lastUpdated": "2026-05-27T12:47:21.972Z",
"lastUpdated": "2026-05-27T13:10:49.174Z",
"agents": {
"lead-developer": {
"current": {
@@ -4931,7 +5043,7 @@ const EMBEDDED_DATA = {
"total_agents": 38,
"agents_with_history": 34,
"pending_recommendations": 0,
"last_sync": "2026-05-27T12:47:21.974Z",
"last_sync": "2026-05-27T13:10:49.175Z",
"sync_sources": [
"git",
"capability-index.yaml",
@@ -4969,6 +5081,16 @@ async function init() {
}
try {
// Load real dashboard data FIRST (overrides stale agent-versions)
try {
const dashRes = await fetch('data/dashboard-data.json');
if (dashRes.ok) {
window.dashboardData = await dashRes.json();
// Sync agentData from dashboard data for all other tabs
syncAgentDataFromDashboard(window.dashboardData);
}
} catch (e) { console.warn('dashboard-data.json not loaded:', e.message); }
document.getElementById('lastSync').textContent = formatDate(agentData.lastUpdated);
document.getElementById('agentCount').textContent = agentData.evolution_metrics.total_agents + ' agents';
document.getElementById('historyCount').textContent = agentData.evolution_metrics.agents_with_history + ' with history';
@@ -4984,12 +5106,69 @@ async function init() {
renderRecommendations();
renderHeatmap();
renderImpact();
renderAnalytics();
} catch (error) {
console.error('Failed to render dashboard:', error);
document.getElementById('lastSync').textContent = 'Error rendering data';
}
}
function syncAgentDataFromDashboard(dd) {
// Convert dashboard format back to agentData format expected by other renders
const agents = {};
const categories = {};
let withHistory = 0;
for (const a of dd.agents || []) {
const cat = a.category || 'General';
if (!categories[cat]) categories[cat] = 0;
categories[cat]++;
const history = a.latest_change ? [{
date: a.latest_change.date,
type: a.latest_change.type,
from: a.latest_change.from,
to: a.latest_change.to,
reason: a.latest_change.reason,
source: a.latest_change.source
}] : [];
if (history.length > 0) withHistory++;
agents[a.name] = {
current: {
model: a.model,
mode: a.mode,
description: a.description,
category: a.category || 'General',
color: a.color || '#8B5CF6',
provider: a.provider || 'Ollama',
variant: a.variant || '',
capabilities: [],
recommendations: [],
benchmark: {
fit_score: a.fit_score || 0,
instruction_following: a.instruction_following || 0
}
},
history: history
};
}
// Update agentData in-place so all render* functions see real data
agentData = {
version: '1.0.0',
lastUpdated: dd.generated,
agents: agents,
evolution_metrics: {
total_agents: dd.total_agents,
agents_with_history: withHistory,
pending_recommendations: 0,
last_sync: dd.generated,
sync_sources: [dd.source || 'dashboard-data', '.kilo/agents/*.md', 'evolution.json']
}
};
}
// Format date
function formatDate(dateStr) {
const date = new Date(dateStr);
@@ -5260,96 +5439,62 @@ function renderRecCard(r, index) {
`;
}
// Render Heatmap
// Render Heatmap — REAL DATA: Agent × Current Model × Real Fit Score
function renderHeatmap() {
const agents = Object.entries(agentData.agents);
if (agents.length === 0) return;
const esc = str => (str || '').replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
const dd = window.dashboardData;
// Build unique model list from all agents
const modelSet = new Set();
const modelIfScores = {};
agents.forEach(([_, a]) => {
const model = a.current.model;
if (model) {
modelSet.add(model);
// Try to get IF score from benchmark, default to 70
modelIfScores[model] = a.current.benchmark?.instruction_following || 70;
}
});
if (!dd || !dd.agents) {
document.getElementById('hmTable').innerHTML = '<tr><td style="color:var(--text-secondary);padding:20px;text-align:center;">⚠️ Нет данных. Запустите анализ.</td></tr>';
return;
}
// Build hmModels array
const hmModels = [...modelSet].map(m => {
// Extract short name from full model ID
let shortName = m;
if (m.includes('qwen3-coder')) shortName = 'Qwen3-Coder';
else if (m.includes('glm-')) shortName = m.includes('5.1') ? 'GLM-5.1' : 'GLM-5';
else if (m.includes('nemotron')) shortName = m.includes('nano') ? 'Nem. Nano' : 'Nem. Super';
else if (m.includes('minimax')) shortName = 'MiniMax M2.5';
else if (m.includes('kimi')) shortName = 'Kimi K2.6';
else if (m.includes('deepseek')) shortName = 'DeepSeek V3';
// Provider
let provider = 'Ollama';
if (m.includes('cloud') || m.includes('ollama-cloud')) provider = 'Ollama Cloud';
else if (m.includes('openrouter')) provider = 'OpenRouter';
else if (m.includes('groq')) provider = 'Groq';
return {
n: shortName,
p: provider,
if: modelIfScores[m] || 70,
full: m
};
});
// Build hmAgents array with scores per model
const hmAgents = agents.map(([name, agent]) => {
const currentModel = agent.current.model;
const currentIdx = hmModels.findIndex(m => m.full === currentModel);
const fitScore = agent.current.benchmark?.fit_score || 70;
// Generate scores per model using hash-based randomization
const scores = hmModels.map((m, idx) => {
if (m.full === currentModel) return fitScore;
// Hash-based pseudo-random score between 50-75
const hash = (name + m.full).split('').reduce((a, c) => a + c.charCodeAt(0), 0);
return 50 + (hash % 26);
const agents = dd.agents;
// Get unique models sorted by count of agents
const modelCounts = {};
agents.forEach(a => { modelCounts[a.model_short] = (modelCounts[a.model_short] || 0) + 1; });
const modelList = Object.entries(modelCounts)
.sort((a, b) => b[1] - a[1])
.map(([short]) => {
const m = dd.models[short] || {};
return {
short,
full: 'ollama-cloud/' + short,
name: m.name || short,
avg_fit: m.avg_fit || 0,
agents: m.agents || 0
};
});
return {
n: name,
c: currentIdx,
s: scores
};
});
// Render the table
// Render table: rows=agents, cols=models
const t = document.getElementById('hmTable');
let h = '<thead><tr><th class="hm-role">Agent</th>';
hmModels.forEach(m => {
const ifColor = m.if >= 85 ? '#00ff94' : m.if >= 75 ? '#facc15' : '#ff6b81';
modelList.forEach(m => {
const color = m.avg_fit >= 85 ? '#00ff94' : m.avg_fit >= 70 ? '#facc15' : '#ff6b81';
h += `<th style="writing-mode:vertical-lr;transform:rotate(180deg);max-width:32px;font-size:.56em;padding:3px 1px;">
${m.n}<br>
<span style="color:${m.p.includes('Cloud') ? 'var(--accent-cyan)' : 'var(--accent-green)'};font-size:.85em">${m.p}</span><br>
<span style="color:${ifColor};font-size:.9em;font-weight:700" title="Instruction Following score">IF:${m.if}</span>
${esc(m.name)}<br>
<span style="color:${color};font-size:.9em;font-weight:700">avg:${m.avg_fit}</span><br>
<span style="color:var(--text-muted);font-size:.8em">${m.agents}</span>
</th>`;
});
h += '</tr></thead><tbody>';
hmAgents.forEach(ag => {
const mx = Math.max(...ag.s);
h += `<tr><td class="hm-r">${ag.n}</td>`;
ag.s.forEach((s, j) => {
const best = s === mx;
const cur = j === ag.c;
const ifLow = hmModels[j].if < 75;
agents.forEach(a => {
h += `<tr><td class="hm-r">${esc(a.name)}</td>`;
modelList.forEach((m, j) => {
const isCurrent = a.model_short === m.short;
const score = isCurrent ? a.fit_score : 0; // Only show score for CURRENT model
const cur = isCurrent;
let marks = '';
if (best) marks += '<span class="hm-star"></span>';
if (ifLow) marks += '<span class="hm-if-warn">⚠</span>';
h += `<td style="background:${hmColor(s)};color:${hmText(s)};cursor:pointer" class="${cur ? 'hm-cur' : ''}" title="${ag.n} × ${hmModels[j].n}: ${s}"
onmouseover="showTT(event,'${ag.n}','${hmModels[j].n} (${hmModels[j].p})',${s},${best},${cur},${hmModels[j].if})"
if (cur) marks += '<span style="border:1px solid var(--accent-cyan);border-radius:50%;padding:1px 3px;font-size:8px"></span>';
const bg = cur ? hmColor(score) : 'transparent';
const txt = cur ? hmText(score) : 'var(--text-muted)';
h += `<td style="background:${bg};color:${txt};cursor:pointer${cur ? ';box-shadow:inset 0 0 0 2px var(--accent-cyan)' : ''}" class="${cur ? 'hm-cur' : ''}"
title="${esc(a.name)}${esc(m.name)}: ${isCurrent ? 'fit=' + a.fit_score + ', if=' + a.instruction_following : 'не использует этот модель'}"
onmouseover="showTT(event,'${esc(a.name)}','${esc(m.name)}',${isCurrent ? a.fit_score : 0},${isCurrent},${cur},${isCurrent ? a.instruction_following : 0})"
onmouseout="hideTT()"
onclick="openHmModal(event, '${ag.n}', '${hmModels[j].n}', ${s}, ${hmModels[j].if})">${s}${marks}</td>`;
onclick="openHmModal(event, '${esc(a.name)}', '${esc(m.name)}', ${isCurrent ? a.fit_score : 0}, ${isCurrent ? a.instruction_following : 0})"
>${isCurrent ? a.fit_score : '·'}${marks}</td>`;
});
h += '</tr>';
});
@@ -6313,6 +6458,190 @@ function closeResearchModal() {
document.getElementById('researchModal').classList.remove('show');
}
/* ===== ANALYTICS HIERARCHY ===== */
function modelScore(model) {
const scores = {
'ollama-cloud/kimi-k2.6': 92,
'ollama-cloud/deepseek-v4-pro-max': 90,
'ollama-cloud/glm-5.1': 82,
'ollama-cloud/qwen3-coder:480b': 88,
'ollama-cloud/qwen3.5-122b': 85,
'ollama-cloud/nemotron-3-super': 88,
'ollama-cloud/minimax-m2.5': 86,
};
return scores[model] || 75;
}
async function renderAnalytics() {
const container = document.getElementById('modelHierarchyTree');
if (!container) return;
let state = null; let loadErr = null;
try {
const r = await fetch('/data/state.json');
if (r.ok) state = await r.json();
else loadErr = 'HTTP ' + r.status;
} catch (e) { loadErr = e.message; }
if (!state || !state.agents) {
const msg = loadErr ? 'Не удалось загрузить данные: ' + loadErr : 'Данные пусты';
const errHtml = `
<div style="padding:20px;color:var(--text-secondary);text-align:center;border:1px dashed var(--border);border-radius:10px">
<div style="font-size:24px;margin-bottom:8px">⚠️</div>
<div style="font-weight:600;color:var(--text-primary);margin-bottom:4px">Аналитика недоступна</div>
<div style="font-size:12px">${esc(msg)}</div>
<div style="font-size:11px;margin-top:8px;color:var(--text-muted)">Убедитесь, что /data/state.json существует и доступен.</div>
</div>`;
document.getElementById('modelHierarchyTree').innerHTML = errHtml;
document.getElementById('categoryBreakdownBars').innerHTML = errHtml;
document.getElementById('fitScoreHeatmap').innerHTML = errHtml;
document.getElementById('commandsMatrixTable').innerHTML = errHtml;
return;
}
renderAnalyticsStats(state);
renderModelHierarchyTree(state.agents);
renderCategoryBreakdownBars(state.agents);
renderFitScoreHeatmap(state.agents);
renderCommandsMatrix(state.commands || []);
}
function esc(str) {
return (str || '').replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
}
// Tab switching
function renderAnalyticsStats(state) {
const el = document.getElementById('analyticsStats');
if (!el) return;
const total = (state.agents || []).length;
const models = new Set((state.agents || []).map(a => a.model)).size;
const cats = new Set((state.agents || []).map(a => a.category)).size;
const cmds = (state.commands || []).length;
el.innerHTML = [
{ label: 'Total Agents', value: total, sub: 'active', grad: 'grad-cyan' },
{ label: 'Models Used', value: models, sub: 'distinct LLMs', grad: 'grad-green' },
{ label: 'Categories', value: cats, sub: 'groups', grad: 'grad-orange' },
{ label: 'Commands', value: cmds, sub: 'slash commands', grad: 'grad-purple' },
].map(s => `
<div class="stat-card">
<div class="stat-label">${s.label}</div>
<div class="stat-value ${s.grad}">${s.value}</div>
<div class="stat-sub">${s.sub}</div>
</div>`).join('');
}
function renderModelHierarchyTree(agents) {
const container = document.getElementById('modelHierarchyTree');
if (!container) return;
const tree = {};
for (const a of agents) {
if (!tree[a.model]) tree[a.model] = {};
const cat = a.category || 'Core';
if (!tree[a.model][cat]) tree[a.model][cat] = [];
tree[a.model][cat].push(a);
}
let html = '';
for (const [model, cats] of Object.entries(tree).sort()) {
const modelShort = model.replace('ollama-cloud/', '');
const total = Object.values(cats).flat().length;
html += `<div class="at-model">
<div class="at-model-header" onclick="this.parentElement.classList.toggle('open')">
<span>${esc(modelShort)}</span>
<span style="margin-left:auto;font-size:11px;color:var(--text-secondary)">${total}</span>
</div>
<div class="at-model-body">`;
for (const [cat, list] of Object.entries(cats).sort()) {
html += `<div class="at-cat">
<div class="at-cat-header" onclick="this.parentElement.classList.toggle('open')">${esc(cat)} (${list.length})</div>
<div class="at-cat-body">`;
for (const a of list) {
const sc = a.fit_score !== undefined ? a.fit_score : modelScore(a.model);
html += `<div class="at-agent">
<span>${esc(a.name)}</span>
<span class="at-agent-badge">${sc}</span>
</div>`;
}
html += '</div></div>';
}
html += '</div></div>';
}
container.innerHTML = html;
const first = container.querySelector('.at-model');
if (first) first.classList.add('open');
}
function renderCategoryBreakdownBars(agents) {
const container = document.getElementById('categoryBreakdownBars');
if (!container) return;
const counts = {};
for (const a of agents) {
const cat = a.category || 'Core';
counts[cat] = (counts[cat] || 0) + 1;
}
const max = Math.max(...Object.values(counts), 1);
let html = '';
for (const [cat, n] of Object.entries(counts).sort((a, b) => b[1] - a[1])) {
const pct = Math.round((n / max) * 100);
html += `
<div class="ab-row">
<div class="ab-label">${esc(cat)}</div>
<div class="ab-track"><div class="ab-fill" style="width:${pct}%"></div></div>
<div class="ab-count">${n}</div>
</div>`;
}
container.innerHTML = html;
}
function renderFitScoreHeatmap(agents) {
const container = document.getElementById('fitScoreHeatmap');
if (!container) return;
let html = '';
for (const a of agents) {
const score = a.fit_score !== undefined ? a.fit_score : modelScore(a.model);
const hue = score >= 85 ? 150 : score >= 70 ? 45 : 0;
const sat = score >= 85 ? '65%' : score >= 70 ? '75%' : '55%';
const light = document.documentElement.getAttribute('data-theme') === 'light' ? '82%' : '30%';
html += `
<div class="ah-cell" style="background:hsl(${hue},${sat},${light})"
onmouseenter="this.querySelector('.ah-tip').style.opacity='1'"
onmouseleave="this.querySelector('.ah-tip').style.opacity='0'"
>
<span class="ah-cell-name">${esc(a.name.slice(0, 12))}</span>
<span class="ah-cell-score">${score}</span>
<div class="ah-tip">${esc(a.name)}${score}</div>
</div>`;
}
container.innerHTML = html;
}
function renderCommandsMatrix(commands) {
const tbody = document.querySelector('#commandsMatrixTable tbody');
if (!tbody) return;
if (!commands.length) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:16px;">No command data available</td></tr>';
return;
}
let html = '';
for (const c of commands) {
const modelShort = (c.model || 'unknown').replace('ollama-cloud/', '');
const score = c.fit_score !== undefined ? c.fit_score : modelScore(c.model);
const cls = score >= 85 ? 'good' : score >= 70 ? 'ok' : 'warn';
html += `
<tr>
<td>/${esc(c.name)}</td>
<td style="font-family:'JetBrains Mono',monospace;font-size:11.5px;color:var(--text-secondary)">${esc(modelShort)}</td>
<td><span class="cm-score ${cls}">${score}</span></td>
<td style="font-size:11.5px;color:var(--text-secondary)">${esc((c.description || '').slice(0, 50))}${(c.description || '').length > 50 ? '…' : ''}</td>
</tr>`;
}
tbody.innerHTML = html;
}
function esc(str) {
return (str || '').replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
}
// Tab switching
function switchTab(tabId, el) {
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));