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>
549 lines
23 KiB
Python
549 lines
23 KiB
Python
"""Human triage UI for AtoCore candidate memories.
|
|
|
|
Renders a lightweight HTML page at /admin/triage with all pending
|
|
candidate memories, each with inline Promote / Reject / Edit buttons.
|
|
No framework, no JS build, no database — reads candidates from the
|
|
AtoCore DB and posts back to the existing REST endpoints.
|
|
|
|
Design principle: the user should be able to triage 20 candidates in
|
|
60 seconds from any browser. Keyboard shortcuts (y/n/e/s) make it
|
|
feel like email triage (archive/delete).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import html as _html
|
|
|
|
from atocore.engineering.wiki import render_html
|
|
from atocore.memory.service import get_memories
|
|
|
|
|
|
VALID_TYPES = ["identity", "preference", "project", "episodic", "knowledge", "adaptation"]
|
|
|
|
|
|
def _escape(s: str | None) -> str:
|
|
return _html.escape(s or "", quote=True)
|
|
|
|
|
|
def _render_candidate_card(cand) -> str:
|
|
"""One candidate row with inline forms for promote/reject/edit."""
|
|
mid = _escape(cand.id)
|
|
content = _escape(cand.content)
|
|
memory_type = _escape(cand.memory_type)
|
|
project = _escape(cand.project or "")
|
|
project_display = project or "(global)"
|
|
confidence = f"{cand.confidence:.2f}"
|
|
refs = cand.reference_count or 0
|
|
created = _escape(str(cand.created_at or ""))
|
|
tags = cand.domain_tags or []
|
|
tags_str = _escape(", ".join(tags))
|
|
valid_until = _escape(cand.valid_until or "")
|
|
# Strip time portion for HTML date input
|
|
valid_until_date = valid_until[:10] if valid_until else ""
|
|
|
|
type_options = "".join(
|
|
f'<option value="{t}"{" selected" if t == cand.memory_type else ""}>{t}</option>'
|
|
for t in VALID_TYPES
|
|
)
|
|
|
|
# Tag badges rendered from current tags
|
|
badges_html = ""
|
|
if tags:
|
|
badges_html = '<div class="cand-tags-display">' + "".join(
|
|
f'<span class="tag-badge">{_escape(t)}</span>' for t in tags
|
|
) + '</div>'
|
|
|
|
return f"""
|
|
<div class="cand" id="cand-{mid}" data-id="{mid}">
|
|
<div class="cand-head">
|
|
<span class="cand-type">[{memory_type}]</span>
|
|
<span class="cand-project">{project_display}</span>
|
|
<span class="cand-meta">conf {confidence} · refs {refs} · {created[:16]}</span>
|
|
</div>
|
|
<div class="cand-body">
|
|
<textarea class="cand-content" id="content-{mid}">{content}</textarea>
|
|
</div>
|
|
{badges_html}
|
|
<div class="cand-meta-row">
|
|
<label class="cand-field-label">Tags:
|
|
<input type="text" class="cand-tags-input" id="tags-{mid}"
|
|
value="{tags_str}" placeholder="optics, thermal, p04" />
|
|
</label>
|
|
<label class="cand-field-label">Valid until:
|
|
<input type="date" class="cand-valid-until" id="valid-until-{mid}"
|
|
value="{valid_until_date}" />
|
|
</label>
|
|
</div>
|
|
<div class="cand-actions">
|
|
<button class="btn-promote" data-id="{mid}" title="Promote (Y)">✅ Promote</button>
|
|
<button class="btn-reject" data-id="{mid}" title="Reject (N)">❌ Reject</button>
|
|
<button class="btn-save-promote" data-id="{mid}" title="Save edits + promote (E)">✏️ Save&Promote</button>
|
|
<label class="cand-type-label">Type:
|
|
<select class="cand-type-select" id="type-{mid}">{type_options}</select>
|
|
</label>
|
|
</div>
|
|
<div class="cand-status" id="status-{mid}"></div>
|
|
</div>
|
|
"""
|
|
|
|
|
|
_TRIAGE_SCRIPT = """
|
|
<script>
|
|
async function apiCall(url, method, body) {
|
|
try {
|
|
const opts = { method };
|
|
if (body) {
|
|
opts.headers = { 'Content-Type': 'application/json' };
|
|
opts.body = JSON.stringify(body);
|
|
}
|
|
const res = await fetch(url, opts);
|
|
return { ok: res.ok, status: res.status, json: res.ok ? await res.json().catch(()=>null) : null };
|
|
} catch (e) { return { ok: false, status: 0, error: String(e) }; }
|
|
}
|
|
|
|
async function requestAutoTriage() {
|
|
const btn = document.getElementById('auto-triage-btn');
|
|
const status = document.getElementById('auto-triage-status');
|
|
if (!btn) return;
|
|
btn.disabled = true;
|
|
btn.textContent = '⏳ Requesting...';
|
|
const r = await apiCall('/admin/triage/request-drain', 'POST');
|
|
if (r.ok) {
|
|
status.textContent = '✓ Requested. Host watcher runs every 2 min. Refresh this page in a minute to check progress.';
|
|
status.className = 'auto-triage-msg ok';
|
|
btn.textContent = '✓ Requested';
|
|
pollDrainStatus();
|
|
} else {
|
|
status.textContent = '❌ Request failed: ' + r.status;
|
|
status.className = 'auto-triage-msg err';
|
|
btn.disabled = false;
|
|
btn.textContent = '🤖 Auto-process queue';
|
|
}
|
|
}
|
|
|
|
async function pollDrainStatus() {
|
|
const status = document.getElementById('auto-triage-status');
|
|
const btn = document.getElementById('auto-triage-btn');
|
|
let polls = 0;
|
|
const timer = setInterval(async () => {
|
|
polls++;
|
|
const r = await apiCall('/admin/triage/drain-status', 'GET');
|
|
if (!r.ok || !r.json) return;
|
|
const s = r.json;
|
|
if (s.is_running) {
|
|
status.textContent = '⚙️ Auto-triage running on host... (started ' + (s.last_started_at || '?') + ')';
|
|
status.className = 'auto-triage-msg ok';
|
|
} else if (s.last_finished_at && !s.requested_at) {
|
|
status.textContent = '✅ Last run finished: ' + s.last_finished_at + ' → ' + (s.last_result || 'complete');
|
|
status.className = 'auto-triage-msg ok';
|
|
if (btn) { btn.disabled = false; btn.textContent = '🤖 Auto-process queue'; }
|
|
clearInterval(timer);
|
|
// Reload page to pick up new queue state
|
|
setTimeout(() => window.location.reload(), 3000);
|
|
}
|
|
if (polls > 60) { clearInterval(timer); } // stop after ~10 min of polling
|
|
}, 10000); // poll every 10s
|
|
}
|
|
|
|
function setStatus(id, msg, ok) {
|
|
const el = document.getElementById('status-' + id);
|
|
if (!el) return;
|
|
el.textContent = msg;
|
|
el.className = 'cand-status ' + (ok ? 'ok' : 'err');
|
|
}
|
|
|
|
function removeCard(id) {
|
|
setTimeout(() => {
|
|
const card = document.getElementById('cand-' + id);
|
|
if (card) {
|
|
card.style.opacity = '0';
|
|
setTimeout(() => card.remove(), 300);
|
|
}
|
|
updateCount();
|
|
}, 400);
|
|
}
|
|
|
|
function updateCount() {
|
|
const n = document.querySelectorAll('.cand').length;
|
|
const el = document.getElementById('cand-count');
|
|
if (el) el.textContent = n;
|
|
const next = document.querySelector('.cand');
|
|
if (next) next.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}
|
|
|
|
async function promote(id) {
|
|
setStatus(id, 'Promoting…', true);
|
|
const r = await apiCall('/memory/' + encodeURIComponent(id) + '/promote', 'POST');
|
|
if (r.ok) { setStatus(id, '✅ Promoted', true); removeCard(id); }
|
|
else setStatus(id, '❌ Failed: ' + r.status, false);
|
|
}
|
|
|
|
async function reject(id) {
|
|
setStatus(id, 'Rejecting…', true);
|
|
const r = await apiCall('/memory/' + encodeURIComponent(id) + '/reject', 'POST');
|
|
if (r.ok) { setStatus(id, '❌ Rejected', true); removeCard(id); }
|
|
else setStatus(id, '❌ Failed: ' + r.status, false);
|
|
}
|
|
|
|
function parseTags(str) {
|
|
return (str || '').split(/[,;]/).map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
}
|
|
|
|
async function savePromote(id) {
|
|
const content = document.getElementById('content-' + id).value.trim();
|
|
const mtype = document.getElementById('type-' + id).value;
|
|
const tagsStr = document.getElementById('tags-' + id)?.value || '';
|
|
const validUntil = document.getElementById('valid-until-' + id)?.value || '';
|
|
if (!content) { setStatus(id, 'Content is empty', false); return; }
|
|
setStatus(id, 'Saving…', true);
|
|
const body = {
|
|
content: content,
|
|
memory_type: mtype,
|
|
domain_tags: parseTags(tagsStr),
|
|
valid_until: validUntil,
|
|
};
|
|
const r1 = await apiCall('/memory/' + encodeURIComponent(id), 'PUT', body);
|
|
if (!r1.ok) { setStatus(id, '❌ Save failed: ' + r1.status, false); return; }
|
|
const r2 = await apiCall('/memory/' + encodeURIComponent(id) + '/promote', 'POST');
|
|
if (r2.ok) { setStatus(id, '✅ Saved & Promoted', true); removeCard(id); }
|
|
else setStatus(id, '❌ Promote failed: ' + r2.status, false);
|
|
}
|
|
|
|
// Also save tag/expiry edits when plain "Promote" is clicked if fields changed
|
|
async function promoteWithMeta(id) {
|
|
const tagsStr = document.getElementById('tags-' + id)?.value || '';
|
|
const validUntil = document.getElementById('valid-until-' + id)?.value || '';
|
|
if (tagsStr.trim() || validUntil) {
|
|
await apiCall('/memory/' + encodeURIComponent(id), 'PUT', {
|
|
domain_tags: parseTags(tagsStr),
|
|
valid_until: validUntil,
|
|
});
|
|
}
|
|
return promote(id);
|
|
}
|
|
|
|
document.addEventListener('click', (e) => {
|
|
const id = e.target.dataset?.id;
|
|
if (!id) return;
|
|
if (e.target.classList.contains('btn-promote')) promoteWithMeta(id);
|
|
else if (e.target.classList.contains('btn-reject')) reject(id);
|
|
else if (e.target.classList.contains('btn-save-promote')) savePromote(id);
|
|
});
|
|
|
|
// Keyboard shortcuts on the currently-focused card
|
|
document.addEventListener('keydown', (e) => {
|
|
// Don't intercept if user is typing in textarea/select/input
|
|
const t = e.target.tagName;
|
|
if (t === 'TEXTAREA' || t === 'INPUT' || t === 'SELECT') return;
|
|
const first = document.querySelector('.cand');
|
|
if (!first) return;
|
|
const id = first.dataset.id;
|
|
if (e.key === 'y' || e.key === 'Y') { e.preventDefault(); promoteWithMeta(id); }
|
|
else if (e.key === 'n' || e.key === 'N') { e.preventDefault(); reject(id); }
|
|
else if (e.key === 'e' || e.key === 'E') {
|
|
e.preventDefault();
|
|
document.getElementById('content-' + id)?.focus();
|
|
}
|
|
else if (e.key === 's' || e.key === 'S') { e.preventDefault(); first.scrollIntoView({behavior:'smooth'}); }
|
|
});
|
|
</script>
|
|
"""
|
|
|
|
|
|
_TRIAGE_CSS = """
|
|
<style>
|
|
.triage-header { display:flex; justify-content:space-between; align-items:baseline; margin-bottom:1rem; }
|
|
.triage-header .count { font-size:1.4rem; font-weight:600; color:var(--accent); }
|
|
.triage-help { background:var(--card); border-left:4px solid var(--accent); padding:0.8rem 1rem; margin-bottom:1.5rem; border-radius:4px; font-size:0.9rem; }
|
|
.triage-help kbd { background:var(--hover); padding:2px 6px; border-radius:3px; font-family:monospace; font-size:0.85em; border:1px solid var(--border); }
|
|
.cand { background:var(--card); border:1px solid var(--border); border-radius:6px; padding:1rem; margin-bottom:1rem; transition:opacity 0.3s; }
|
|
.cand-head { display:flex; gap:0.8rem; align-items:center; margin-bottom:0.6rem; font-size:0.9rem; }
|
|
.cand-type { font-weight:600; color:var(--accent); font-family:monospace; }
|
|
.cand-project { color:var(--text); opacity:0.8; font-family:monospace; }
|
|
.cand-meta { color:var(--text); opacity:0.55; font-size:0.8rem; margin-left:auto; }
|
|
.cand-content { width:100%; min-height:80px; font-family:inherit; font-size:0.95rem; padding:0.5rem; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:4px; resize:vertical; box-sizing:border-box; }
|
|
.cand-content:focus { outline:none; border-color:var(--accent); }
|
|
.cand-actions { display:flex; gap:0.5rem; margin-top:0.8rem; align-items:center; flex-wrap:wrap; }
|
|
.cand-actions button { padding:0.4rem 0.9rem; border:1px solid var(--border); background:var(--card); color:var(--text); border-radius:4px; cursor:pointer; font-size:0.88rem; }
|
|
.cand-actions button:hover { background:var(--hover); }
|
|
.btn-promote:hover { background:#059669; color:white; border-color:#059669; }
|
|
.btn-reject:hover { background:#dc2626; color:white; border-color:#dc2626; }
|
|
.btn-save-promote:hover { background:var(--accent); color:white; border-color:var(--accent); }
|
|
.cand-type-label { font-size:0.85rem; margin-left:auto; opacity:0.7; }
|
|
.cand-type-select { padding:0.25rem; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:3px; font-family:monospace; }
|
|
.cand-status { margin-top:0.5rem; font-size:0.85rem; min-height:1.2em; }
|
|
.cand-status.ok { color:#059669; }
|
|
.cand-status.err { color:#dc2626; }
|
|
.empty { text-align:center; padding:3rem; opacity:0.6; }
|
|
.auto-triage-bar { display:flex; gap:0.8rem; align-items:center; background:var(--card); border:1px solid var(--border); border-radius:6px; padding:0.7rem 1rem; margin-bottom:1.2rem; flex-wrap:wrap; }
|
|
.auto-triage-bar button { padding:0.55rem 1.1rem; border:1px solid var(--accent); background:var(--accent); color:white; border-radius:4px; cursor:pointer; font-weight:600; font-size:0.95rem; }
|
|
.auto-triage-bar button:hover:not(:disabled) { opacity:0.9; }
|
|
.auto-triage-bar button:disabled { opacity:0.5; cursor:not-allowed; }
|
|
.auto-triage-msg { flex:1; min-width:200px; font-size:0.85rem; opacity:0.75; }
|
|
.auto-triage-msg.ok { color:var(--accent); opacity:1; font-weight:500; }
|
|
.auto-triage-msg.err { color:#dc2626; opacity:1; font-weight:500; }
|
|
.cand-tags-display { margin-top:0.5rem; display:flex; gap:0.35rem; flex-wrap:wrap; }
|
|
.tag-badge { background:var(--accent); color:white; padding:0.15rem 0.55rem; border-radius:10px; font-size:0.72rem; font-family:monospace; font-weight:500; }
|
|
.cand-meta-row { display:flex; gap:0.8rem; margin-top:0.6rem; align-items:center; flex-wrap:wrap; }
|
|
.cand-field-label { display:flex; gap:0.3rem; align-items:center; font-size:0.85rem; opacity:0.75; }
|
|
.cand-tags-input { flex:1; min-width:200px; padding:0.3rem 0.5rem; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:3px; font-family:monospace; font-size:0.85rem; }
|
|
.cand-tags-input:focus { outline:none; border-color:var(--accent); }
|
|
.cand-valid-until { padding:0.3rem; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:3px; font-family:inherit; font-size:0.85rem; }
|
|
</style>
|
|
"""
|
|
|
|
|
|
def _render_entity_card(entity) -> str:
|
|
"""Phase 5: entity candidate card with promote/reject."""
|
|
eid = _escape(entity.id)
|
|
name = _escape(entity.name)
|
|
etype = _escape(entity.entity_type)
|
|
project = _escape(entity.project or "(global)")
|
|
desc = _escape(entity.description or "")
|
|
conf = f"{entity.confidence:.2f}"
|
|
src_refs = entity.source_refs or []
|
|
source_display = _escape(", ".join(src_refs[:3])) if src_refs else "(no provenance)"
|
|
|
|
return f"""
|
|
<div class="cand cand-entity" id="ecand-{eid}" data-entity-id="{eid}">
|
|
<div class="cand-head">
|
|
<span class="cand-type entity-type">[entity · {etype}]</span>
|
|
<span class="cand-project">{project}</span>
|
|
<span class="cand-meta">conf {conf} · src: {source_display}</span>
|
|
</div>
|
|
<div class="cand-body">
|
|
<div class="entity-name">{name}</div>
|
|
<div class="entity-desc">{desc}</div>
|
|
</div>
|
|
<div class="cand-actions">
|
|
<button class="btn-entity-promote" data-entity-id="{eid}" title="Promote entity (Y)">✅ Promote Entity</button>
|
|
<button class="btn-entity-reject" data-entity-id="{eid}" title="Reject entity (N)">❌ Reject</button>
|
|
<a class="btn-link" href="/wiki/entities/{eid}">View in wiki →</a>
|
|
</div>
|
|
<div class="cand-status" id="estatus-{eid}"></div>
|
|
</div>
|
|
"""
|
|
|
|
|
|
_ENTITY_TRIAGE_SCRIPT = """
|
|
<script>
|
|
async function entityPromote(id) {
|
|
const st = document.getElementById('estatus-' + id);
|
|
st.textContent = 'Promoting…';
|
|
st.className = 'cand-status ok';
|
|
const r = await fetch('/entities/' + encodeURIComponent(id) + '/promote', {method:'POST'});
|
|
if (r.ok) {
|
|
st.textContent = '✅ Entity promoted';
|
|
setTimeout(() => {
|
|
const card = document.getElementById('ecand-' + id);
|
|
if (card) { card.style.opacity = '0'; setTimeout(() => card.remove(), 300); }
|
|
}, 400);
|
|
} else st.textContent = '❌ ' + r.status;
|
|
}
|
|
async function entityReject(id) {
|
|
const st = document.getElementById('estatus-' + id);
|
|
st.textContent = 'Rejecting…';
|
|
st.className = 'cand-status ok';
|
|
const r = await fetch('/entities/' + encodeURIComponent(id) + '/reject', {method:'POST'});
|
|
if (r.ok) {
|
|
st.textContent = '❌ Entity rejected';
|
|
setTimeout(() => {
|
|
const card = document.getElementById('ecand-' + id);
|
|
if (card) { card.style.opacity = '0'; setTimeout(() => card.remove(), 300); }
|
|
}, 400);
|
|
} else st.textContent = '❌ ' + r.status;
|
|
}
|
|
document.addEventListener('click', (e) => {
|
|
const eid = e.target.dataset?.entityId;
|
|
if (!eid) return;
|
|
if (e.target.classList.contains('btn-entity-promote')) entityPromote(eid);
|
|
else if (e.target.classList.contains('btn-entity-reject')) entityReject(eid);
|
|
});
|
|
</script>
|
|
"""
|
|
|
|
_ENTITY_TRIAGE_CSS = """
|
|
<style>
|
|
.cand-entity { border-left: 3px solid #059669; }
|
|
.entity-type { background: #059669; color: white; padding: 0.1rem 0.5rem; border-radius: 3px; font-size: 0.75rem; }
|
|
.entity-name { font-size: 1.15rem; font-weight: 600; margin-bottom: 0.3rem; }
|
|
.entity-desc { opacity: 0.85; font-size: 0.95rem; }
|
|
.btn-entity-promote { background: #059669; color: white; border-color: #059669; }
|
|
.btn-entity-reject:hover { background: #dc2626; color: white; border-color: #dc2626; }
|
|
.btn-link { padding: 0.4rem 0.9rem; text-decoration: none; color: var(--accent); border: 1px solid var(--border); border-radius: 4px; font-size: 0.88rem; }
|
|
.btn-link:hover { background: var(--hover); }
|
|
.section-break { border-top: 2px solid var(--border); margin: 2rem 0 1rem 0; padding-top: 1rem; }
|
|
</style>
|
|
"""
|
|
|
|
|
|
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
|
|
|
|
try:
|
|
mem_candidates = get_memories(status="candidate", limit=limit)
|
|
except Exception as e:
|
|
body = f"<p>Error loading memory candidates: {_escape(str(e))}</p>"
|
|
return render_html("Triage — AtoCore", body, breadcrumbs=[("Wiki", "/wiki"), ("Triage", "")])
|
|
|
|
try:
|
|
entity_candidates = get_entities(status="candidate", limit=limit)
|
|
except Exception as e:
|
|
entity_candidates = []
|
|
|
|
total = len(mem_candidates) + len(entity_candidates)
|
|
graduation_bar = _render_graduation_bar()
|
|
|
|
if total == 0:
|
|
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
|
|
mem_cards = "".join(_render_candidate_card(c) for c in mem_candidates)
|
|
|
|
# Entity cards
|
|
ent_cards_html = ""
|
|
if entity_candidates:
|
|
ent_cards = "".join(_render_entity_card(e) for e in entity_candidates)
|
|
ent_cards_html = f"""
|
|
<div class="section-break">
|
|
<h2>🔧 Entity Candidates ({len(entity_candidates)})</h2>
|
|
<p class="auto-triage-msg">
|
|
Typed graph entries awaiting review. Promoting an entity connects it to
|
|
the engineering knowledge graph (subsystems, requirements, decisions, etc.).
|
|
</p>
|
|
</div>
|
|
{ent_cards}
|
|
"""
|
|
|
|
body = _TRIAGE_CSS + _ENTITY_TRIAGE_CSS + f"""
|
|
<div class="triage-header">
|
|
<h1>Triage Queue</h1>
|
|
<span class="count">
|
|
<span id="cand-count">{len(mem_candidates)}</span> memory ·
|
|
{len(entity_candidates)} entity
|
|
</span>
|
|
</div>
|
|
<div class="triage-help">
|
|
Review candidates the auto-triage wasn't sure about. Edit the content
|
|
if needed, then promote or reject. Shortcuts: <kbd>Y</kbd> promote · <kbd>N</kbd>
|
|
reject · <kbd>E</kbd> edit · <kbd>S</kbd> scroll to next.
|
|
</div>
|
|
<div class="auto-triage-bar">
|
|
<button id="auto-triage-btn" onclick="requestAutoTriage()" title="Run auto_triage on Dalidou host">
|
|
🤖 Auto-process queue
|
|
</button>
|
|
<span id="auto-triage-status" class="auto-triage-msg">
|
|
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 + _GRADUATION_SCRIPT
|
|
|
|
return render_html(
|
|
"Triage — AtoCore",
|
|
body,
|
|
breadcrumbs=[("Wiki", "/wiki"), ("Triage", "")],
|
|
)
|