feat: /admin/triage web UI + auto-drain loop

Makes human triage sustainable. Before: command-line-only review,
auto-triage stopped after 100 candidates/run. Now:

1. Web UI at /admin/triage
   - Lists all pending candidates with inline promote/reject/edit
   - Edit content in-place before promoting (PUT /memory/{id})
   - Change type via dropdown
   - Keyboard shortcuts: Y=promote, N=reject, E=edit, S=scroll-next
   - Cards fade out after action, queue count updates live
   - Zero JS framework — vanilla fetch + event delegation

2. auto_triage.py drains queue
   - Loops up to 20 batches (default) of 100 candidates each
   - Tracks seen IDs so needs_human items don't reprocess
   - Exits cleanly when queue empty
   - Nightly cron naturally drains everything

3. Dashboard + wiki surface triage queue
   - Dashboard /admin/dashboard: new "triage" section with pending
     count + /admin/triage URL + warning/notice severity levels
   - Wiki homepage: prominent callout "N candidates awaiting triage —
     review now" linking to /admin/triage, styled with triage-warning
     (>50) or triage-notice (>20) CSS

Pattern: human intervenes only when AI can't decide. The UI makes
that intervention fast (20 candidates in 60 seconds). Nightly
auto-triage drains the queue so the human queue stays bounded.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 20:28:56 -04:00
parent 86637f8eee
commit d8b370fd0a
4 changed files with 334 additions and 62 deletions

View File

@@ -209,75 +209,81 @@ def main():
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
parser.add_argument("--model", default=DEFAULT_MODEL)
parser.add_argument("--dry-run", action="store_true", help="preview without executing")
parser.add_argument("--max-batches", type=int, default=20,
help="Max batches of 100 to process per run (default 20 = 2000 candidates)")
args = parser.parse_args()
# Fetch candidates
result = api_get(args.base_url, "/memory?status=candidate&limit=100")
candidates = result.get("memories", [])
print(f"candidates: {len(candidates)} model: {args.model} dry_run: {args.dry_run}")
if not candidates:
print("queue empty, nothing to triage")
return
# Cache active memories per project for dedup
active_cache = {}
# Track IDs we've already seen so needs_human items don't re-process
# every batch (they stay in the candidate table until a human reviews).
seen_ids: set[str] = set()
active_cache: dict[str, list] = {}
promoted = rejected = needs_human = errors = 0
batch_num = 0
for i, cand in enumerate(candidates, 1):
# Light rate-limit pacing: 0.5s between triage calls so a burst
# doesn't overwhelm the claude CLI's backend. With ~60s per call
# this is negligible overhead but avoids the "all-failed" pattern
# we saw on large batches.
if i > 1:
time.sleep(0.5)
while batch_num < args.max_batches:
batch_num += 1
project = cand.get("project") or ""
if project not in active_cache:
active_cache[project] = fetch_active_memories_for_project(args.base_url, project)
result = api_get(args.base_url, "/memory?status=candidate&limit=100")
all_candidates = result.get("memories", [])
# Filter out already-seen (needs_human from prior batch in same run)
candidates = [c for c in all_candidates if c["id"] not in seen_ids]
verdict_obj = triage_one(cand, active_cache[project], args.model, DEFAULT_TIMEOUT_S)
verdict = verdict_obj["verdict"]
conf = verdict_obj["confidence"]
reason = verdict_obj["reason"]
conflicts_with = verdict_obj.get("conflicts_with", "")
mid = cand["id"]
label = f"[{i:2d}/{len(candidates)}] {mid[:8]} [{cand['memory_type']}]"
if verdict == "promote" and conf >= AUTO_PROMOTE_MIN_CONFIDENCE:
if args.dry_run:
print(f" WOULD PROMOTE {label} conf={conf:.2f} {reason}")
if not candidates:
if batch_num == 1:
print("queue empty, nothing to triage")
else:
try:
api_post(args.base_url, f"/memory/{mid}/promote")
print(f" PROMOTED {label} conf={conf:.2f} {reason}")
active_cache[project].append(cand)
except Exception:
errors += 1
promoted += 1
elif verdict == "reject":
if args.dry_run:
print(f" WOULD REJECT {label} conf={conf:.2f} {reason}")
else:
try:
api_post(args.base_url, f"/memory/{mid}/reject")
print(f" REJECTED {label} conf={conf:.2f} {reason}")
except Exception:
errors += 1
rejected += 1
elif verdict == "contradicts":
# Leave candidate in queue but flag the conflict in content
# so the wiki/triage shows it. This is conservative: we
# don't silently merge or reject when sources disagree.
print(f" CONTRADICTS {label} vs {conflicts_with[:8] if conflicts_with else '?'} {reason}")
contradicts_count = locals().get('contradicts_count', 0) + 1
needs_human += 1
else:
print(f" NEEDS_HUMAN {label} conf={conf:.2f} {reason}")
needs_human += 1
print(f"\nQueue drained after batch {batch_num-1}.")
break
print(f"\npromoted={promoted} rejected={rejected} needs_human={needs_human} errors={errors}")
print(f"\n=== batch {batch_num}: {len(candidates)} candidates model: {args.model} dry_run: {args.dry_run} ===")
for i, cand in enumerate(candidates, 1):
if i > 1:
time.sleep(0.5)
seen_ids.add(cand["id"])
project = cand.get("project") or ""
if project not in active_cache:
active_cache[project] = fetch_active_memories_for_project(args.base_url, project)
verdict_obj = triage_one(cand, active_cache[project], args.model, DEFAULT_TIMEOUT_S)
verdict = verdict_obj["verdict"]
conf = verdict_obj["confidence"]
reason = verdict_obj["reason"]
conflicts_with = verdict_obj.get("conflicts_with", "")
mid = cand["id"]
label = f"[{i:2d}/{len(candidates)}] {mid[:8]} [{cand['memory_type']}]"
if verdict == "promote" and conf >= AUTO_PROMOTE_MIN_CONFIDENCE:
if args.dry_run:
print(f" WOULD PROMOTE {label} conf={conf:.2f} {reason}")
else:
try:
api_post(args.base_url, f"/memory/{mid}/promote")
print(f" PROMOTED {label} conf={conf:.2f} {reason}")
active_cache[project].append(cand)
except Exception:
errors += 1
promoted += 1
elif verdict == "reject":
if args.dry_run:
print(f" WOULD REJECT {label} conf={conf:.2f} {reason}")
else:
try:
api_post(args.base_url, f"/memory/{mid}/reject")
print(f" REJECTED {label} conf={conf:.2f} {reason}")
except Exception:
errors += 1
rejected += 1
elif verdict == "contradicts":
print(f" CONTRADICTS {label} vs {conflicts_with[:8] if conflicts_with else '?'} {reason}")
needs_human += 1
else:
print(f" NEEDS_HUMAN {label} conf={conf:.2f} {reason}")
needs_human += 1
print(f"\ntotal: promoted={promoted} rejected={rejected} needs_human={needs_human} errors={errors} batches={batch_num}")
if __name__ == "__main__":

View File

@@ -119,6 +119,17 @@ def wiki_search(q: str = "") -> HTMLResponse:
return HTMLResponse(content=render_search(q))
@router.get("/admin/triage", response_class=HTMLResponse)
def admin_triage(limit: int = 100) -> HTMLResponse:
"""Human triage UI for candidate memories.
Lists pending candidates with inline promote/reject/edit buttons.
Keyboard shortcuts: Y=promote, N=reject, E=edit content.
"""
from atocore.engineering.triage_ui import render_triage_page
return HTMLResponse(content=render_triage_page(limit=limit))
# --- Request/Response models ---
@@ -1022,6 +1033,16 @@ def api_dashboard() -> dict:
except Exception:
pass
# Triage queue health
triage: dict = {
"pending": len(candidates),
"review_url": "/admin/triage",
}
if len(candidates) > 50:
triage["warning"] = f"High queue: {len(candidates)} candidates pending review."
elif len(candidates) > 20:
triage["notice"] = f"{len(candidates)} candidates awaiting triage."
return {
"memories": {
"active": len(active),
@@ -1037,6 +1058,7 @@ def api_dashboard() -> dict:
"interactions": interaction_stats,
"extraction_pipeline": extract_state,
"pipeline": pipeline,
"triage": triage,
}

View File

@@ -0,0 +1,231 @@
"""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 ""))
type_options = "".join(
f'<option value="{t}"{" selected" if t == cand.memory_type else ""}>{t}</option>'
for t in VALID_TYPES
)
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>
<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="Edit + 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 };
} catch (e) { return { ok: false, status: 0, error: String(e) }; }
}
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);
}
async function savePromote(id) {
const content = document.getElementById('content-' + id).value.trim();
const mtype = document.getElementById('type-' + id).value;
if (!content) { setStatus(id, 'Content is empty', false); return; }
setStatus(id, 'Saving…', true);
const r1 = await apiCall('/memory/' + encodeURIComponent(id), 'PUT', {
content: content, memory_type: mtype
});
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);
}
document.addEventListener('click', (e) => {
const id = e.target.dataset?.id;
if (!id) return;
if (e.target.classList.contains('btn-promote')) promote(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(); promote(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; }
</style>
"""
def render_triage_page(limit: int = 100) -> str:
"""Render the full triage page with all pending candidates."""
try:
candidates = get_memories(status="candidate", limit=limit)
except Exception as e:
body = f"<p>Error loading candidates: {_escape(str(e))}</p>"
return render_html("Triage — AtoCore", body, breadcrumbs=[("Wiki", "/wiki"), ("Triage", "")])
if not candidates:
body = _TRIAGE_CSS + """
<div class="triage-header">
<h1>Triage Queue</h1>
</div>
<div class="empty">
<p>🎉 No candidates to review.</p>
<p>The auto-triage pipeline keeps this queue empty unless something needs your judgment.</p>
</div>
"""
return render_html("Triage — AtoCore", body, breadcrumbs=[("Wiki", "/wiki"), ("Triage", "")])
cards_html = "".join(_render_candidate_card(c) for c in candidates)
body = _TRIAGE_CSS + f"""
<div class="triage-header">
<h1>Triage Queue</h1>
<span class="count"><span id="cand-count">{len(candidates)}</span> pending</span>
</div>
<div class="triage-help">
Review candidate memories 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>
{cards_html}
""" + _TRIAGE_SCRIPT
return render_html(
"Triage — AtoCore",
body,
breadcrumbs=[("Wiki", "/wiki"), ("Triage", "")],
)

View File

@@ -119,9 +119,19 @@ def render_homepage() -> str:
# Quick stats
all_entities = get_entities(limit=500)
all_memories = get_memories(active_only=True, limit=500)
pending = get_memories(status="candidate", limit=500)
lines.append('<h2>System</h2>')
lines.append(f'<p>{len(all_entities)} entities · {len(all_memories)} active memories · {len(projects)} projects</p>')
lines.append(f'<p><a href="/admin/dashboard">API Dashboard (JSON)</a> · <a href="/health">Health Check</a></p>')
# Triage queue prompt — surfaced prominently if non-empty
if pending:
tone = "triage-warning" if len(pending) > 50 else "triage-notice"
lines.append(
f'<p class="{tone}">🗂️ <strong>{len(pending)} candidates</strong> awaiting triage — '
f'<a href="/admin/triage">review now →</a></p>'
)
lines.append(f'<p><a href="/admin/triage">Triage Queue</a> · <a href="/admin/dashboard">API Dashboard (JSON)</a> · <a href="/health">Health Check</a></p>')
return render_html("AtoCore Wiki", "\n".join(lines))
@@ -289,6 +299,9 @@ _TEMPLATE = """<!DOCTYPE html>
.card .stats { font-size: 0.8em; margin-top: 0.5rem; opacity: 0.5; }
.card .client { font-size: 0.85em; opacity: 0.65; margin-bottom: 0.3rem; font-style: italic; }
.card h3 .tag { font-size: 0.65em; vertical-align: middle; margin-left: 0.4rem; }
.triage-notice { background: var(--card); border-left: 4px solid var(--accent); padding: 0.6rem 1rem; border-radius: 4px; margin: 0.8rem 0; }
.triage-warning { background: #fef3c7; color: #78350f; border-left: 4px solid #d97706; padding: 0.6rem 1rem; border-radius: 4px; margin: 0.8rem 0; }
@media (prefers-color-scheme: dark) { .triage-warning { background: #451a03; color: #fde68a; } }
</style>
</head>
<body>