feat: one-click memory graduation button + host watcher
Closes the graduation UX loop: no more SSH required to populate the
entity graph from memories. Click button → host watcher picks up
→ graduation runs → entity candidates appear in the same triage UI.
New API endpoints (src/atocore/api/routes.py):
- POST /admin/graduation/request: takes {project, limit}, writes flag
to project_state. Host watcher picks up within 2 min.
- GET /admin/graduation/status: returns requested/running/last_result
fields for UI polling.
Triage UI (src/atocore/engineering/triage_ui.py):
- Graduation bar with:
- 🎓 Graduate memories button
- Project selector populated from registry (or "all projects")
- Limit number input (default 30, max 200)
- Status message area
- Poll every 10s until is_running=false, then auto-reload the page to
show new entity candidates in the Entity section below
- Graduation bar appears on both populated and empty triage page
states so you can kick off graduation from either
Host watcher (deploy/dalidou/graduation-watcher.sh):
- Mirrors auto-triage-watcher.sh pattern: poll, lock, clear flag,
run, record result, unlock
- Parses {project, limit} JSON from the flag payload
- Runs graduate_memories.py with those args
- Records graduation_running/started/finished/last_result in project
state for the UI to display
- Lock file prevents concurrent runs
Install on host (one-time, via cron):
*/2 * * * * /srv/storage/atocore/app/deploy/dalidou/graduation-watcher.sh \
>> /home/papa/atocore-logs/graduation-watcher.log 2>&1
This completes the Phase 5 self-service loop: queue triage happens
autonomously via the 3-tier escalation (shipped in 3ca1972); entity
graph population happens autonomously via a button click. No shell
required for daily use.
Tests: 366 passing (no new tests — UI + shell are integration-level).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -377,6 +377,92 @@ _ENTITY_TRIAGE_CSS = """
|
||||
"""
|
||||
|
||||
|
||||
def _render_graduation_bar() -> str:
|
||||
"""The 'Graduate memories → entity candidates' control bar."""
|
||||
from atocore.projects.registry import load_project_registry
|
||||
try:
|
||||
projects = load_project_registry()
|
||||
options = '<option value="">(all projects)</option>' + "".join(
|
||||
f'<option value="{_escape(p.project_id)}">{_escape(p.project_id)}</option>'
|
||||
for p in projects
|
||||
)
|
||||
except Exception:
|
||||
options = '<option value="">(all projects)</option>'
|
||||
|
||||
return f"""
|
||||
<div class="auto-triage-bar graduation-bar">
|
||||
<button id="grad-btn" onclick="requestGraduation()" title="Run memory→entity graduation on Dalidou host">
|
||||
🎓 Graduate memories
|
||||
</button>
|
||||
<label class="cand-field-label">Project:
|
||||
<select id="grad-project" class="cand-type-select">{options}</select>
|
||||
</label>
|
||||
<label class="cand-field-label">Limit:
|
||||
<input id="grad-limit" type="number" class="cand-tags-input" style="max-width:80px"
|
||||
value="30" min="1" max="200" />
|
||||
</label>
|
||||
<span id="grad-status" class="auto-triage-msg">
|
||||
Scans active memories, asks the LLM "does this describe a typed entity?",
|
||||
and creates entity candidates. Review them in the Entity section below.
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
_GRADUATION_SCRIPT = """
|
||||
<script>
|
||||
async function requestGraduation() {
|
||||
const btn = document.getElementById('grad-btn');
|
||||
const status = document.getElementById('grad-status');
|
||||
const project = document.getElementById('grad-project').value;
|
||||
const limit = parseInt(document.getElementById('grad-limit').value || '30', 10);
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Requesting...';
|
||||
const r = await fetch('/admin/graduation/request', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({project, limit}),
|
||||
});
|
||||
if (r.ok) {
|
||||
const scope = project || 'all projects';
|
||||
status.textContent = `✓ Queued graduation for ${scope} (limit ${limit}). Host watcher runs every 2 min; refresh this page in ~3 min to see candidates.`;
|
||||
status.className = 'auto-triage-msg ok';
|
||||
btn.textContent = '✓ Requested';
|
||||
pollGraduationStatus();
|
||||
} else {
|
||||
status.textContent = '❌ Request failed: ' + r.status;
|
||||
status.className = 'auto-triage-msg err';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🎓 Graduate memories';
|
||||
}
|
||||
}
|
||||
|
||||
async function pollGraduationStatus() {
|
||||
const status = document.getElementById('grad-status');
|
||||
const btn = document.getElementById('grad-btn');
|
||||
let polls = 0;
|
||||
const timer = setInterval(async () => {
|
||||
polls++;
|
||||
const r = await fetch('/admin/graduation/status');
|
||||
if (!r.ok) return;
|
||||
const s = await r.json();
|
||||
if (s.is_running) {
|
||||
status.textContent = '⚙️ Graduation running... (started ' + (s.last_started_at || '?') + ')';
|
||||
status.className = 'auto-triage-msg ok';
|
||||
} else if (s.last_finished_at && !s.requested) {
|
||||
status.textContent = '✅ Finished: ' + s.last_finished_at + ' → ' + (s.last_result || 'complete');
|
||||
status.className = 'auto-triage-msg ok';
|
||||
if (btn) { btn.disabled = false; btn.textContent = '🎓 Graduate memories'; }
|
||||
clearInterval(timer);
|
||||
setTimeout(() => window.location.reload(), 3000);
|
||||
}
|
||||
if (polls > 120) { clearInterval(timer); } // ~20 min cap
|
||||
}, 10000);
|
||||
}
|
||||
</script>
|
||||
"""
|
||||
|
||||
|
||||
def render_triage_page(limit: int = 100) -> str:
|
||||
"""Render the full triage page with pending memory + entity candidates."""
|
||||
from atocore.engineering.service import get_entities
|
||||
@@ -393,17 +479,20 @@ def render_triage_page(limit: int = 100) -> str:
|
||||
entity_candidates = []
|
||||
|
||||
total = len(mem_candidates) + len(entity_candidates)
|
||||
graduation_bar = _render_graduation_bar()
|
||||
|
||||
if total == 0:
|
||||
body = _TRIAGE_CSS + _ENTITY_TRIAGE_CSS + """
|
||||
body = _TRIAGE_CSS + _ENTITY_TRIAGE_CSS + f"""
|
||||
<div class="triage-header">
|
||||
<h1>Triage Queue</h1>
|
||||
</div>
|
||||
{graduation_bar}
|
||||
<div class="empty">
|
||||
<p>🎉 No candidates to review.</p>
|
||||
<p>The auto-triage pipeline keeps this queue empty unless something needs your judgment.</p>
|
||||
<p>Use the 🎓 Graduate memories button above to propose new entity candidates from existing memories.</p>
|
||||
</div>
|
||||
"""
|
||||
""" + _GRADUATION_SCRIPT
|
||||
return render_html("Triage — AtoCore", body, breadcrumbs=[("Wiki", "/wiki"), ("Triage", "")])
|
||||
|
||||
# Memory cards
|
||||
@@ -442,14 +531,15 @@ def render_triage_page(limit: int = 100) -> str:
|
||||
🤖 Auto-process queue
|
||||
</button>
|
||||
<span id="auto-triage-status" class="auto-triage-msg">
|
||||
Sends the full memory queue through LLM triage on the host. Entity candidates
|
||||
stay for manual review (types + relationships matter too much to auto-decide).
|
||||
Sends the full memory queue through 3-tier LLM triage on the host.
|
||||
Sonnet → Opus → auto-discard. Only genuinely ambiguous items land here.
|
||||
</span>
|
||||
</div>
|
||||
{graduation_bar}
|
||||
<h2>📝 Memory Candidates ({len(mem_candidates)})</h2>
|
||||
{mem_cards}
|
||||
{ent_cards_html}
|
||||
""" + _TRIAGE_SCRIPT + _ENTITY_TRIAGE_SCRIPT
|
||||
""" + _TRIAGE_SCRIPT + _ENTITY_TRIAGE_SCRIPT + _GRADUATION_SCRIPT
|
||||
|
||||
return render_html(
|
||||
"Triage — AtoCore",
|
||||
|
||||
Reference in New Issue
Block a user