feat(brain): brain search dashboard + scheduled auto-ingest + skill-run auto-upsert
1) Brain Search page (dashboard/pages/brain-search.js): mirrors Agent Insights; queries /api/brain-index/search with source filter + live index stats; nav entry + PAGE_TITLES added. 2) Scheduled auto-ingest: scheduler gains endpoint-style jobs (run_endpoint_via_api) so non-skill maintenance tasks can be cron'd; new job brain-index-ingest-job.json re-indexes every 30 min. 3) Agents auto-upsert learnings: run_skill wrap-up now calls record_brain_learning() so each skill run is immediately searchable in the unified index (best-effort, never breaks the run). Fixed a regression where AGENT_STATS_FILE def was dropped during the brain-learning helper insertion (would have broken all skill runs). Verified live: brain search returns ranked results; run_skill wrap-up upserts a searchable doc; scheduler imports + job validates.
This commit is contained in:
parent
a36dff7f2c
commit
257d64327a
|
|
@ -93,6 +93,10 @@ const api = {
|
|||
recomputeAgentTime: () => api.post('/api/agent-time/recompute', {}),
|
||||
// Agent Insights (AI usage analytics)
|
||||
getAgentInsights: (recompute = false) => api.get(`/api/agent-insights${recompute ? '?recompute=true' : ''}`),
|
||||
// Brain Index (centralized knowledge pipeline)
|
||||
getBrainIndexSearch: (q, source = '', limit = 20) => api.get(`/api/brain-index/search?q=${encodeURIComponent(q)}&limit=${limit}${source ? `&source=${encodeURIComponent(source)}` : ''}`),
|
||||
getBrainIndexStats: () => api.get('/api/brain-index/stats'),
|
||||
brainIndexIngest: () => api.post('/api/brain-index/ingest', {}),
|
||||
// Agent Registry
|
||||
getAgents: () => api.get('/api/agents'),
|
||||
registerAgent: (data) => api.post('/api/agents/register', data),
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@
|
|||
<a href="#smart-router" class="nav-item" data-page="smart-router"><span class="nav-icon">🧭</span><span class="nav-label">Smart Router</span></a>
|
||||
<a href="#learning-analytics" class="nav-item" data-page="learning-analytics"><span class="nav-icon">📊</span><span class="nav-label">Learning Analytics</span></a>
|
||||
<a href="#agent-insights" class="nav-item" data-page="agent-insights"><span class="nav-icon">🧠</span><span class="nav-label">Agent Insights</span></a>
|
||||
<a href="#brain-search" class="nav-item" data-page="brain-search"><span class="nav-icon">🔍</span><span class="nav-label">Brain Search</span></a>
|
||||
<a href="#session-replay" class="nav-item" data-page="session-replay"><span class="nav-icon">🔄</span><span class="nav-label">Session Replay</span></a>
|
||||
<a href="#agent-time" class="nav-item" data-page="agent-time"><span class="nav-icon">⏱</span><span class="nav-label">Agent Time</span></a>
|
||||
<div class="sidebar-section"><div class="sidebar-section-label">Management</div></div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
// Brain Search — query the centralized brain index (brain/ notes,
|
||||
// skill learnings, chat history) via /api/brain-index/search.
|
||||
|
||||
async function renderBrainSearch() {
|
||||
const content = document.getElementById('pageContent');
|
||||
content.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div class="page-header-left">
|
||||
<h1 class="page-title">Brain Search</h1>
|
||||
<p class="page-subtitle">Full-text search across your unified knowledge: notes, skill learnings, chat history</p>
|
||||
</div>
|
||||
<button class="btn btn-secondary" onclick="brainSearchIngest()">⟳ Re-ingest</button>
|
||||
</div>
|
||||
<div class="card mb-4">
|
||||
<div class="flex gap-2 items-center">
|
||||
<input id="brainQuery" class="form-input" style="flex:1" placeholder="Search the brain… (e.g. router, gemini, deployment)" onkeydown="if(event.key==='Enter')brainSearchRun()" />
|
||||
<select id="brainSource" class="form-input" style="max-width:180px">
|
||||
<option value="">All sources</option>
|
||||
<option value="brain">📝 Notes (brain/)</option>
|
||||
<option value="skill-learning">🧠 Skill learnings</option>
|
||||
<option value="chat">💬 Chat history</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" onclick="brainSearchRun()">Search</button>
|
||||
</div>
|
||||
<div id="brainStats" class="mt-2" style="font-size:11px;color:var(--text-muted)"></div>
|
||||
</div>
|
||||
<div id="brainResults"><div class="empty-state"><div class="empty-state-icon">🔍</div><div class="empty-state-title">Search your brain</div><div class="empty-state-desc">Type a query above to search across all indexed knowledge.</div></div></div>
|
||||
`;
|
||||
// Load stats + initial hint.
|
||||
try {
|
||||
const s = await api.getBrainIndexStats();
|
||||
const parts = Object.entries(s)
|
||||
.filter(([k]) => k !== 'total')
|
||||
.map(([k, v]) => `${k}: ${v}`);
|
||||
document.getElementById('brainStats').textContent =
|
||||
`Indexed: ${s.total || 0} docs (${parts.join(' · ')})`;
|
||||
} catch (e) {
|
||||
document.getElementById('brainStats').textContent = 'Index not built yet — click Re-ingest.';
|
||||
}
|
||||
}
|
||||
|
||||
async function brainSearchRun() {
|
||||
const q = document.getElementById('brainQuery').value.trim();
|
||||
const source = document.getElementById('brainSource').value;
|
||||
const box = document.getElementById('brainResults');
|
||||
if (!q) { box.innerHTML = '<div class="empty-state"><div class="empty-state-title">Enter a search term</div></div>'; return; }
|
||||
box.innerHTML = '<div class="loading"><div class="loading-spinner"></div><span>Searching…</span></div>';
|
||||
try {
|
||||
const data = await api.getBrainIndexSearch(q, source);
|
||||
if (!data.results.length) {
|
||||
box.innerHTML = '<div class="empty-state"><div class="empty-state-icon">🔍</div><div class="empty-state-title">No results</div><div class="empty-state-desc">Nothing matched "' + escapeHtml(q) + '"' + (source ? ' in ' + escapeHtml(source) : '') + '.</div></div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = `<div class="page-subtitle mb-2">${data.count} result(s) for "${escapeHtml(q)}"</div>` +
|
||||
data.results.map(r => {
|
||||
const srcIcon = { 'brain': '📝', 'skill-learning': '🧠', 'chat': '💬', 'agent-note': '🤖' }[r.source] || '📄';
|
||||
const agent = r.agent ? `<span class="badge badge-accent" style="font-size:10px;margin-left:6px">${escapeHtml(r.agent)}</span>` : '';
|
||||
return `
|
||||
<div class="card mb-2" style="border-left:4px solid var(--accent)">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span>${srcIcon}</span>
|
||||
<strong style="font-size:13px">${escapeHtml(r.title)}</strong>
|
||||
<span class="badge" style="font-size:10px;opacity:.7">${escapeHtml(r.source)}</span>
|
||||
${agent}
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);white-space:pre-wrap">${escapeHtml(r.snippet || '')}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
box.innerHTML = `<div class="empty-state"><div class="empty-state-icon">⚠</div><div class="empty-state-title">Search failed</div><div class="empty-state-desc">${escapeHtml(err.message)}</div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function brainSearchIngest() {
|
||||
const btn = event && event.target;
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Ingesting…'; }
|
||||
try {
|
||||
const r = await api.brainIndexIngest();
|
||||
const s = r.stats || {};
|
||||
if (btn) btn.textContent = '✓ Re-ingest';
|
||||
const statEl = document.getElementById('brainStats');
|
||||
if (statEl) {
|
||||
const parts = Object.entries(s).filter(([k]) => k !== 'total').map(([k, v]) => `${k}: ${v}`);
|
||||
statEl.textContent = `Indexed: ${s.total || 0} docs (${parts.join(' · ')})`;
|
||||
}
|
||||
} catch (e) {
|
||||
if (btn) btn.textContent = 'Ingest failed';
|
||||
} finally {
|
||||
if (btn) setTimeout(() => { btn.disabled = false; btn.textContent = '⟳ Re-ingest'; }, 1500);
|
||||
}
|
||||
}
|
||||
|
|
@ -118,6 +118,7 @@ const PAGE_TITLES = {
|
|||
audit: { title: 'Audit Log', breadcrumb: 'System activity trail' },
|
||||
cost: { title: 'Cost Analytics', breadcrumb: 'Usage & spending' },
|
||||
'agent-insights': { title: 'Agent Insights', breadcrumb: 'AI usage analytics' },
|
||||
'brain-search': { title: 'Brain Search', breadcrumb: 'Unified knowledge index' },
|
||||
plugins: { title: 'Plugin Registry', breadcrumb: 'Manage plugins' },
|
||||
backups: { title: 'Backups', breadcrumb: 'Disaster recovery' },
|
||||
prompts: { title: 'Prompt Library', breadcrumb: 'Reusable templates' },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "brain-index-ingest",
|
||||
"name": "Brain Index Auto-Ingest",
|
||||
"endpoint": "/api/brain-index/ingest",
|
||||
"cron": "*/30 * * * *",
|
||||
"enabled": true,
|
||||
"created": "2026-07-25T00:00:00Z",
|
||||
"last_run": null,
|
||||
"next_run": null
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ def log_audit(entry: dict):
|
|||
def run_skill_via_api(skill_name: str, agent: str = "auto") -> dict:
|
||||
"""Invoke a skill through the live server API.
|
||||
|
||||
Returns a dict with at least {'ok': bool, 'reason'/'output': ...}.
|
||||
Returns a dict with at least {'ok': bool, 'reason'/'data': ...}.
|
||||
"""
|
||||
url = f"{SERVER_URL}/api/skills/{skill_name}/run"
|
||||
payload = json.dumps({"input": "", "agent": agent}).encode()
|
||||
|
|
@ -68,32 +68,58 @@ def run_skill_via_api(skill_name: str, agent: str = "auto") -> dict:
|
|||
return {"ok": False, "reason": str(e)}
|
||||
|
||||
|
||||
def run_job(job: dict):
|
||||
skill = job.get("skill")
|
||||
name = job.get("name", skill)
|
||||
agent = job.get("agent", "auto")
|
||||
print(f"[{datetime.now().isoformat()}] Firing job '{name}' -> skill '{skill}'")
|
||||
log_audit({"action": "scheduler_run", "job": name, "skill": skill, "stage": "start"})
|
||||
def run_endpoint_via_api(endpoint: str) -> dict:
|
||||
"""POST to an arbitrary server endpoint (e.g. /api/brain-index/ingest)
|
||||
so scheduler jobs can trigger maintenance tasks that aren't skills."""
|
||||
url = f"{SERVER_URL}{endpoint}"
|
||||
req = urllib.request.Request(
|
||||
url, data=b"{}", headers={"Content-Type": "application/json"}, method="POST"
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=RUN_TIMEOUT) as resp:
|
||||
data = json.loads(resp.read())
|
||||
return {"ok": True, "data": data}
|
||||
except urllib.error.HTTPError as e:
|
||||
return {"ok": False, "reason": f"HTTP {e.code}: {e.reason}"}
|
||||
except urllib.error.URLError as e:
|
||||
return {"ok": False, "reason": f"server unreachable: {e.reason}"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "reason": str(e)}
|
||||
|
||||
if not skill:
|
||||
log_audit({"action": "scheduler_run", "job": name, "error": "no skill defined"})
|
||||
|
||||
def run_job(job: dict):
|
||||
name = job.get("name", job.get("skill") or job.get("endpoint"))
|
||||
agent = job.get("agent", "auto")
|
||||
endpoint = job.get("endpoint")
|
||||
skill = job.get("skill")
|
||||
print(f"[{datetime.now().isoformat()}] Firing job '{name}' -> "
|
||||
f"{('endpoint ' + endpoint) if endpoint else ('skill ' + str(skill))}")
|
||||
log_audit({"action": "scheduler_run", "job": name,
|
||||
"skill": skill, "endpoint": endpoint, "stage": "start"})
|
||||
|
||||
if endpoint:
|
||||
result = run_endpoint_via_api(endpoint)
|
||||
elif skill:
|
||||
result = run_skill_via_api(skill, agent)
|
||||
else:
|
||||
log_audit({"action": "scheduler_run", "job": name, "error": "no skill or endpoint defined"})
|
||||
return
|
||||
|
||||
result = run_skill_via_api(skill, agent)
|
||||
if result["ok"]:
|
||||
data = result["data"]
|
||||
data = result.get("data", {})
|
||||
log_audit({
|
||||
"action": "scheduler_run",
|
||||
"job": name,
|
||||
"skill": skill,
|
||||
"agent": data.get("agent"),
|
||||
"run_id": data.get("run_id"),
|
||||
"endpoint": endpoint,
|
||||
"result": data,
|
||||
"stage": "done",
|
||||
})
|
||||
print(f" -> OK (agent={data.get('agent')}, run_id={data.get('run_id')})")
|
||||
print(f" -> OK")
|
||||
else:
|
||||
reason = result["reason"]
|
||||
log_audit({"action": "scheduler_run", "job": name, "skill": skill, "error": reason})
|
||||
log_audit({"action": "scheduler_run", "job": name,
|
||||
"skill": skill, "endpoint": endpoint, "error": reason})
|
||||
print(f" -> FAILED: {reason}")
|
||||
|
||||
|
||||
|
|
@ -112,20 +138,22 @@ def load_jobs(scheduler: BackgroundScheduler):
|
|||
continue
|
||||
cron = data.get("cron")
|
||||
skill = data.get("skill")
|
||||
if not cron or not skill:
|
||||
print(f" Skipping job {data.get('name')}: missing cron or skill")
|
||||
endpoint = data.get("endpoint")
|
||||
if not cron or not (skill or endpoint):
|
||||
print(f" Skipping job {data.get('name')}: missing cron and (skill or endpoint)")
|
||||
continue
|
||||
scheduler.add_job(
|
||||
run_job,
|
||||
CronTrigger.from_crontab(cron),
|
||||
args=[data],
|
||||
id=data.get("id", data["name"]),
|
||||
name=data.get("name", skill),
|
||||
name=data.get("name", skill or endpoint),
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
print(f" Scheduled: {data.get('name')} (skill={skill}, cron={cron})")
|
||||
print(f" Scheduled: {data.get('name')} "
|
||||
f"({'endpoint=' + endpoint if endpoint else 'skill=' + str(skill)}, cron={cron})")
|
||||
|
||||
|
||||
def main():
|
||||
|
|
|
|||
39
server.py
39
server.py
|
|
@ -187,6 +187,34 @@ def append_audit(entry: dict):
|
|||
# underlying operation, but surface it on the server console.
|
||||
print(f"[audit] failed to write entry {entry.get('action')!r}: {e}")
|
||||
|
||||
|
||||
def record_brain_learning(source: str, source_path: str, title: str,
|
||||
content: str, agent: str | None = None,
|
||||
updated_at: str | None = None) -> bool:
|
||||
"""Push a single document into the centralized brain index.
|
||||
|
||||
Best-effort: a brain-index failure must never break the calling
|
||||
operation (e.g. a skill run). Calls the same engine the CLI/HTTP
|
||||
endpoints use, synchronously — no HTTP round-trip.
|
||||
"""
|
||||
try:
|
||||
mod = _brain_index_module()
|
||||
conn = mod.get_conn()
|
||||
try:
|
||||
mod.upsert_doc(
|
||||
conn, source=source, source_path=source_path, title=title,
|
||||
content=content, agent=agent, updated_at=updated_at,
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"[brain-index] learning upsert failed ({source}/{source_path}): {e}")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
# ─── Agent Stats (real, persisted) ──────────────────────────────
|
||||
AGENT_STATS_FILE = BASE_DIR / "data" / "agent-stats.json"
|
||||
|
||||
|
|
@ -858,6 +886,17 @@ def run_skill(name: str, req: Optional[SkillRunRequest] = None):
|
|||
)
|
||||
write_file(path / "learnings.md", existing + new_entry)
|
||||
|
||||
# Also push this learning into the centralized brain index so it is
|
||||
# immediately searchable alongside brain/ notes and chat history.
|
||||
record_brain_learning(
|
||||
source="skill-learning",
|
||||
source_path=str(path.relative_to(BASE_DIR) / "learnings.md") + f"#{run_id}",
|
||||
title=f"{name} — learning {run_id}",
|
||||
content=f"## {timestamp} (Run {run_id})\n- Agent: {agent_choice}\n- Input: {skill_input or '(none)'}\n- Output: {response_text[:500]}",
|
||||
agent=agent_choice,
|
||||
updated_at=get_timestamp(),
|
||||
)
|
||||
|
||||
# Log execution
|
||||
append_audit({
|
||||
"action": "skill_run",
|
||||
|
|
|
|||
Loading…
Reference in New Issue