feat: DB-driven KB topic manager — admin UI + migration

- Create kb_generator_topics table (140 topics migrated from hardcoded array)
- kb_intent_generator.php now loads active topics from DB instead of $BATCHES array
- Admin panel: MANAGE TOPICS button opens full topic manager modal
  - Browse all 140 topics with search, category, and active/disabled filters
  - Inline active toggle per topic (checkbox)
  - Add new topic form (topic_id, category, name, description)
  - Edit existing topics (all fields)
  - Delete topics with confirmation
  - Run count display
- Backend API cases: kb_topics, kb_topic_save, kb_topic_delete, kb_topic_toggle
- Topics can now be added/managed without any code changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
This commit is contained in:
2026-07-02 07:04:20 -05:00
parent a13f750846
commit 7327104612
2 changed files with 275 additions and 283 deletions
+265
View File
@@ -616,6 +616,57 @@ if ($action) {
}
j(['lines'=>$out, 'next_line'=>$total]);
// ── KB GENERATOR TOPICS ─────────────────────────────────────────────
case 'kb_topics':
$where = ['1=1']; $params = [];
if (!empty($_GET['q'])) {
$q2 = '%'.$_GET['q'].'%';
$where[] = '(topic_name LIKE ? OR topic_id LIKE ? OR category LIKE ? OR description LIKE ?)';
$params = array_merge($params, [$q2,$q2,$q2,$q2]);
}
if (isset($_GET['cat']) && $_GET['cat'] !== '') { $where[] = 'category=?'; $params[] = $_GET['cat']; }
if (isset($_GET['active']) && $_GET['active'] !== '') { $where[] = 'active=?'; $params[] = (int)$_GET['active']; }
$topics = JarvisDB::query(
'SELECT id,topic_id,category,topic_name,description,active,run_count,last_run_at
FROM kb_generator_topics WHERE '.implode(' AND ',$where).' ORDER BY category,topic_name',
$params
);
$catRows = JarvisDB::query('SELECT DISTINCT category FROM kb_generator_topics ORDER BY category');
j(['topics'=>$topics, 'categories'=>array_column($catRows,'category')]);
case 'kb_topic_save':
$id = (int)($_POST['id'] ?? 0);
$tid = preg_replace('/[^a-z0-9_]/', '_', strtolower(trim($_POST['topic_id'] ?? '')));
$cat = trim($_POST['category'] ?? '');
$name = trim($_POST['topic_name'] ?? '');
$desc = trim($_POST['description'] ?? '');
$act = (int)!empty($_POST['active']);
if (!$tid||!$cat||!$name||!$desc) bad('All fields required');
if ($id) {
JarvisDB::execute(
'UPDATE kb_generator_topics SET topic_id=?,category=?,topic_name=?,description=?,active=?,updated_at=NOW() WHERE id=?',
[$tid,$cat,$name,$desc,$act,$id]
);
j(['ok'=>true,'msg'=>'Topic updated']);
} else {
JarvisDB::execute(
'INSERT INTO kb_generator_topics (topic_id,category,topic_name,description,active) VALUES (?,?,?,?,?)',
[$tid,$cat,$name,$desc,$act]
);
j(['ok'=>true,'msg'=>'Topic created']);
}
case 'kb_topic_delete':
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
JarvisDB::execute('DELETE FROM kb_generator_topics WHERE id=?', [$id]);
j(['ok'=>true]);
case 'kb_topic_toggle':
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
JarvisDB::execute('UPDATE kb_generator_topics SET active=NOT active, updated_at=NOW() WHERE id=?', [$id]);
$row = JarvisDB::single('SELECT active FROM kb_generator_topics WHERE id=?', [$id]);
j(['ok'=>true,'active'=>(bool)$row['active']]);
case 'arc_setup_log':
$logFile = '/var/log/jarvis/arc-setup.log';
$since = max(0, (int)($_GET['since'] ?? 0));
@@ -1498,6 +1549,7 @@ select.filter-sel:focus{border-color:var(--cyan)}
<button class="btn btn-sm btn-green" onclick="intentModal()">+ ADD INTENT</button>
<button class="btn btn-sm btn-yellow" onclick="intentTestModal()">TEST PATTERN</button>
<button class="btn btn-sm" onclick="loadIntents()">REFRESH</button>
<button class="btn btn-sm" style="border-color:#7f5fff;color:#7f5fff" onclick="openTopicsManager()">&#9881; MANAGE TOPICS</button>
<button class="btn btn-sm" style="border-color:#00d4ff;color:#00d4ff" onclick="runIntentGenerator()">&#9654; RUN GENERATOR</button>
</div>
</div>
@@ -2386,6 +2438,219 @@ async function runIntentGenerator() {
setTimeout(pollLog, 2000);
}
let _topicsData = [];
let _topicsCats = [];
async function openTopicsManager() {
const modalHtml = `
<div style="font-family:var(--mono);font-size:0.72rem">
<div id="tm-view-list">
<div style="display:flex;gap:8px;margin-bottom:8px;align-items:center;flex-wrap:wrap">
<input id="tm-search" type="text" placeholder="Search topics…" style="flex:1;min-width:140px;background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit" oninput="tmFilter()">
<select id="tm-cat-filter" style="background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit" onchange="tmFilter()">
<option value="">ALL CATEGORIES</option>
</select>
<select id="tm-active-filter" style="background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit" onchange="tmFilter()">
<option value="">ALL STATUS</option>
<option value="1">ACTIVE</option>
<option value="0">DISABLED</option>
</select>
<button class="btn btn-sm btn-green" onclick="tmEditTopic(null)">+ ADD</button>
</div>
<div id="tm-stats" style="color:var(--text-dim);font-size:0.6rem;margin-bottom:6px;letter-spacing:0.5px"></div>
<div id="tm-list" style="max-height:370px;overflow-y:auto"><div class="loading">LOADING…</div></div>
</div>
<div id="tm-view-edit" style="display:none">
<div style="margin-bottom:12px">
<button class="btn btn-sm" onclick="tmShowList()">&#8592; BACK</button>
<span id="tm-edit-title" style="color:var(--cyan);margin-left:12px;font-size:0.65rem;letter-spacing:1px"></span>
</div>
<div style="display:grid;gap:8px">
<input type="hidden" id="tm-edit-id">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<label style="font-size:0.6rem;color:var(--text-dim)">TOPIC ID (slug)
<input id="tm-edit-tid" type="text" placeholder="e.g. math_arith" style="width:100%;margin-top:3px;background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit;box-sizing:border-box">
</label>
<label style="font-size:0.6rem;color:var(--text-dim)">CATEGORY
<input id="tm-edit-cat" type="text" placeholder="e.g. mathematics" list="tm-cat-dl" style="width:100%;margin-top:3px;background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit;box-sizing:border-box">
<datalist id="tm-cat-dl"></datalist>
</label>
</div>
<label style="font-size:0.6rem;color:var(--text-dim)">TOPIC NAME
<input id="tm-edit-name" type="text" placeholder="e.g. Arithmetic and number sense" style="width:100%;margin-top:3px;background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit;box-sizing:border-box">
</label>
<label style="font-size:0.6rem;color:var(--text-dim)">DESCRIPTION (subtopics to cover comma-separated)
<textarea id="tm-edit-desc" rows="5" placeholder="e.g. fractions, decimals, order of operations, prime numbers…" style="width:100%;margin-top:3px;background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit;resize:vertical;box-sizing:border-box"></textarea>
</label>
<label style="font-size:0.6rem;color:var(--text-dim);display:flex;align-items:center;gap:6px;cursor:pointer">
<input id="tm-edit-active" type="checkbox" checked style="cursor:pointer"> ACTIVE (included in rotation)
</label>
</div>
</div>
</div>`;
openModal('&#9881; KB TOPIC MANAGER', modalHtml, null, null);
const saveBtn = document.getElementById('modalSave');
const closeBtn = document.getElementById('modalClose');
saveBtn.textContent = 'SAVE TOPIC';
saveBtn.style.display = 'none';
saveBtn.onclick = tmSaveTopic;
closeBtn.onclick = closeModal;
await tmLoadTopics();
}
async function tmLoadTopics() {
const res = await api('kb_topics');
_topicsData = res?.topics || [];
_topicsCats = res?.categories || [];
const catSel = document.getElementById('tm-cat-filter');
if (catSel) {
const cur = catSel.value;
while (catSel.options.length > 1) catSel.remove(1);
_topicsCats.forEach(c => catSel.add(new Option(c.toUpperCase(), c)));
if (cur) catSel.value = cur;
}
const dl = document.getElementById('tm-cat-dl');
if (dl) { dl.innerHTML = ''; _topicsCats.forEach(c => { const o = document.createElement('option'); o.value=c; dl.appendChild(o); }); }
tmFilter();
}
function tmFilter() {
const q = (document.getElementById('tm-search')?.value || '').toLowerCase();
const cat = document.getElementById('tm-cat-filter')?.value || '';
const active = document.getElementById('tm-active-filter')?.value;
const rows = _topicsData.filter(t => {
if (cat && t.category !== cat) return false;
if (active !== '' && active !== null && active !== undefined && String(t.active) !== String(active)) return false;
if (q && !`${t.topic_name} ${t.topic_id} ${t.category} ${t.description}`.toLowerCase().includes(q)) return false;
return true;
});
const total = _topicsData.length;
const activeCount = _topicsData.filter(t => t.active == 1).length;
const statsEl = document.getElementById('tm-stats');
if (statsEl) statsEl.innerHTML =
`<span style="color:var(--cyan)">${total} topics</span> &nbsp;|&nbsp; ` +
`<span style="color:var(--green)">${activeCount} active</span> &nbsp;|&nbsp; ` +
`<span style="color:var(--yellow)">${total-activeCount} disabled</span>` +
(rows.length < total ? ` &nbsp;|&nbsp; <span style="color:var(--text-dim)">${rows.length} shown</span>` : '');
const listEl = document.getElementById('tm-list');
if (!listEl) return;
if (!rows.length) {
listEl.innerHTML = '<div style="color:var(--text-dim);padding:20px;text-align:center">No topics match filter</div>';
return;
}
listEl.innerHTML = `<table style="width:100%;border-collapse:collapse">
<thead><tr style="color:var(--cyan);font-size:0.58rem;border-bottom:1px solid var(--border)">
<th style="padding:4px 6px;text-align:left;font-weight:normal">TOPIC ID</th>
<th style="padding:4px 6px;text-align:left;font-weight:normal">CATEGORY</th>
<th style="padding:4px 6px;text-align:left;font-weight:normal">TOPIC NAME</th>
<th style="padding:4px 6px;text-align:center;font-weight:normal">ON</th>
<th style="padding:4px 6px;text-align:center;font-weight:normal">RUNS</th>
<th style="padding:4px 6px;text-align:right;font-weight:normal">ACTIONS</th>
</tr></thead>
<tbody>${rows.map(t => `
<tr style="border-bottom:1px solid rgba(255,255,255,0.03);font-size:0.65rem">
<td style="padding:4px 6px;color:var(--text-dim);font-size:0.57rem">${tmEsc(t.topic_id)}</td>
<td style="padding:4px 6px"><span style="background:var(--bg2);padding:1px 5px;border-radius:2px;font-size:0.57rem;white-space:nowrap">${tmEsc(t.category)}</span></td>
<td style="padding:4px 6px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${tmEsc(t.description)}">${tmEsc(t.topic_name)}</td>
<td style="padding:4px 6px;text-align:center">
<input type="checkbox" ${t.active==1?'checked':''} onchange="tmToggle(${t.id},this)" style="cursor:pointer;accent-color:var(--green)">
</td>
<td style="padding:4px 6px;text-align:center;color:var(--text-dim);font-size:0.6rem">${t.run_count||0}</td>
<td style="padding:4px 6px;text-align:right;white-space:nowrap">
<button class="btn btn-sm btn-yellow" style="padding:2px 7px;font-size:0.58rem" onclick="tmEditTopic(${t.id})">EDIT</button>
<button class="btn btn-sm btn-red" style="padding:2px 7px;font-size:0.58rem;margin-left:3px" onclick="tmDelete(${t.id},${JSON.stringify(t.topic_name)})">DEL</button>
</td>
</tr>`).join('')}
</tbody></table>`;
}
function tmShowList() {
document.getElementById('tm-view-list').style.display = '';
document.getElementById('tm-view-edit').style.display = 'none';
document.getElementById('modalSave').style.display = 'none';
}
function tmEditTopic(id) {
document.getElementById('tm-view-list').style.display = 'none';
document.getElementById('tm-view-edit').style.display = '';
document.getElementById('modalSave').style.display = '';
const tidEl = document.getElementById('tm-edit-tid');
if (id) {
const t = _topicsData.find(x => x.id == id);
if (!t) return;
document.getElementById('tm-edit-title').textContent = 'EDIT TOPIC: ' + t.topic_id;
document.getElementById('tm-edit-id').value = t.id;
tidEl.value = t.topic_id;
tidEl.readOnly = true;
tidEl.style.opacity = '0.6';
document.getElementById('tm-edit-cat').value = t.category;
document.getElementById('tm-edit-name').value = t.topic_name;
document.getElementById('tm-edit-desc').value = t.description;
document.getElementById('tm-edit-active').checked = t.active == 1;
} else {
document.getElementById('tm-edit-title').textContent = 'ADD NEW TOPIC';
document.getElementById('tm-edit-id').value = '';
tidEl.value = '';
tidEl.readOnly = false;
tidEl.style.opacity = '1';
document.getElementById('tm-edit-cat').value = '';
document.getElementById('tm-edit-name').value = '';
document.getElementById('tm-edit-desc').value = '';
document.getElementById('tm-edit-active').checked = true;
}
}
async function tmSaveTopic() {
const id = document.getElementById('tm-edit-id').value;
const data = {
id: id ? parseInt(id) : 0,
topic_id: document.getElementById('tm-edit-tid').value.trim(),
category: document.getElementById('tm-edit-cat').value.trim(),
topic_name: document.getElementById('tm-edit-name').value.trim(),
description: document.getElementById('tm-edit-desc').value.trim(),
active: document.getElementById('tm-edit-active').checked ? 1 : 0,
};
if (!data.topic_id || !data.category || !data.topic_name || !data.description) {
toast('All fields are required', 'err'); return;
}
apiPost('kb_topic_save', data, async (res) => {
toast(res?.msg || 'Saved', 'ok');
tmShowList();
await tmLoadTopics();
});
}
async function tmToggle(id, el) {
apiPost('kb_topic_toggle', {id}, (res) => {
const t = _topicsData.find(x => x.id == id);
if (t) t.active = res.active ? 1 : 0;
tmFilter();
});
}
async function tmDelete(id, name) {
if (!confirm('Delete topic "' + name + '"?\nThis cannot be undone.')) return;
apiPost('kb_topic_delete', {id}, async () => {
toast('Topic deleted', 'ok');
await tmLoadTopics();
});
}
function tmEsc(s) {
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
async function arcSetup() {
const modalHtml = `