diff --git a/scripts/auto_triage.py b/scripts/auto_triage.py index 7635a42..bb17129 100644 --- a/scripts/auto_triage.py +++ b/scripts/auto_triage.py @@ -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__": diff --git a/src/atocore/api/routes.py b/src/atocore/api/routes.py index a6dbec2..6b3b21b 100644 --- a/src/atocore/api/routes.py +++ b/src/atocore/api/routes.py @@ -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, } diff --git a/src/atocore/engineering/triage_ui.py b/src/atocore/engineering/triage_ui.py new file mode 100644 index 0000000..36fc21c --- /dev/null +++ b/src/atocore/engineering/triage_ui.py @@ -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'' + for t in VALID_TYPES + ) + + return f""" +
Error loading candidates: {_escape(str(e))}
" + return render_html("Triage — AtoCore", body, breadcrumbs=[("Wiki", "/wiki"), ("Triage", "")]) + + if not candidates: + body = _TRIAGE_CSS + """ +🎉 No candidates to review.
+The auto-triage pipeline keeps this queue empty unless something needs your judgment.
+{len(all_entities)} entities · {len(all_memories)} active memories · {len(projects)} projects
') - lines.append(f'API Dashboard (JSON) · Health Check
') + + # Triage queue prompt — surfaced prominently if non-empty + if pending: + tone = "triage-warning" if len(pending) > 50 else "triage-notice" + lines.append( + f'🗂️ {len(pending)} candidates awaiting triage — ' + f'review now →
' + ) + + lines.append(f'') return render_html("AtoCore Wiki", "\n".join(lines)) @@ -289,6 +299,9 @@ _TEMPLATE = """ .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; } }