From 0dfecb3c14c7297fb45e5560fb3d937f19f766f4 Mon Sep 17 00:00:00 2001 From: Anto01 Date: Fri, 17 Apr 2026 09:45:12 -0400 Subject: [PATCH] feat: one-click memory graduation button + host watcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- deploy/dalidou/graduation-watcher.sh | 117 +++++++++++++++++++++++++++ src/atocore/api/routes.py | 71 ++++++++++++++++ src/atocore/engineering/triage_ui.py | 100 +++++++++++++++++++++-- 3 files changed, 283 insertions(+), 5 deletions(-) create mode 100644 deploy/dalidou/graduation-watcher.sh diff --git a/deploy/dalidou/graduation-watcher.sh b/deploy/dalidou/graduation-watcher.sh new file mode 100644 index 0000000..9bd5643 --- /dev/null +++ b/deploy/dalidou/graduation-watcher.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# +# deploy/dalidou/graduation-watcher.sh +# ------------------------------------ +# Host-side watcher for on-demand memoryβ†’entity graduation from the web UI. +# +# The /admin/triage page has a "πŸŽ“ Graduate memories" button that POSTs +# to /admin/graduation/request with {project, limit}. The container +# writes this to project_state (atocore/config/graduation_requested_at). +# +# This script runs on the Dalidou HOST (where claude CLI lives), polls +# for the flag, and runs graduate_memories.py when seen. +# +# Installed via cron every 2 minutes: +# */2 * * * * /srv/storage/atocore/app/deploy/dalidou/graduation-watcher.sh \ +# >> /home/papa/atocore-logs/graduation-watcher.log 2>&1 +# +# Safety: +# - Lock file prevents concurrent runs +# - Flag cleared before processing so duplicate clicks queue at most one re-run +# - Fail-open: any error logs but doesn't break the host + +set -euo pipefail + +ATOCORE_URL="${ATOCORE_URL:-http://127.0.0.1:8100}" +APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +LOCK_FILE="/tmp/atocore-graduation.lock" +LOG_DIR="/home/papa/atocore-logs" +mkdir -p "$LOG_DIR" + +TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +log() { printf '[%s] %s\n' "$TS" "$*"; } + +# Fetch the flag via API +STATE_JSON=$(curl -sSf --max-time 5 "$ATOCORE_URL/project/state/atocore" 2>/dev/null || echo "{}") +REQUESTED=$(echo "$STATE_JSON" | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + for e in d.get('entries', d.get('state', [])): + if e.get('category') == 'config' and e.get('key') == 'graduation_requested_at': + print(e.get('value', '')) + break +except Exception: + pass +" 2>/dev/null || echo "") + +if [[ -z "$REQUESTED" ]]; then + exit 0 +fi + +# Parse JSON: {project, limit, requested_at} +PROJECT=$(echo "$REQUESTED" | python3 -c "import sys,json; d=json.load(sys.stdin) if '{' in sys.stdin.buffer.peek().decode(errors='ignore') else None; print((d or {}).get('project',''))" 2>/dev/null || echo "") +# Fallback: python inline above can be flaky; just re-parse +PROJECT=$(echo "$REQUESTED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read() or '{}').get('project',''))" 2>/dev/null || echo "") +LIMIT=$(echo "$REQUESTED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read() or '{}').get('limit',30))" 2>/dev/null || echo "30") + +# Acquire lock +exec 9>"$LOCK_FILE" || exit 0 +if ! flock -n 9; then + log "graduation already running, skipping" + exit 0 +fi + +# Mark running +curl -sSf -X POST "$ATOCORE_URL/project/state" \ + -H 'Content-Type: application/json' \ + -d "{\"project\":\"atocore\",\"category\":\"status\",\"key\":\"graduation_running\",\"value\":\"1\",\"source\":\"host watcher\"}" \ + >/dev/null 2>&1 || true +curl -sSf -X POST "$ATOCORE_URL/project/state" \ + -H 'Content-Type: application/json' \ + -d "{\"project\":\"atocore\",\"category\":\"status\",\"key\":\"graduation_last_started_at\",\"value\":\"$TS\",\"source\":\"host watcher\"}" \ + >/dev/null 2>&1 || true + +LOG_FILE="$LOG_DIR/graduation-ondemand-$(date -u +%Y%m%d-%H%M%S).log" +log "Starting graduation (project='$PROJECT' limit=$LIMIT, log: $LOG_FILE)" + +# Clear the flag BEFORE running so duplicate clicks queue at most one +curl -sSf -X DELETE "$ATOCORE_URL/project/state" \ + -H 'Content-Type: application/json' \ + -d "{\"project\":\"atocore\",\"category\":\"config\",\"key\":\"graduation_requested_at\"}" \ + >/dev/null 2>&1 || true + +# Build script args +cd "$APP_DIR" +export PYTHONPATH="$APP_DIR/src:${PYTHONPATH:-}" +ARGS=(--base-url "$ATOCORE_URL" --limit "$LIMIT") +if [[ -n "$PROJECT" ]]; then + ARGS+=(--project "$PROJECT") +fi + +if python3 scripts/graduate_memories.py "${ARGS[@]}" >> "$LOG_FILE" 2>&1; then + RESULT=$(tail -3 "$LOG_FILE" | grep "^total:" | tail -1 || tail -1 "$LOG_FILE") + RESULT="${RESULT:-completed}" + log "graduation finished: $RESULT" +else + RESULT="ERROR β€” see $LOG_FILE" + log "graduation FAILED" +fi + +FINISH_TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +# Mark done +curl -sSf -X POST "$ATOCORE_URL/project/state" \ + -H 'Content-Type: application/json' \ + -d "{\"project\":\"atocore\",\"category\":\"status\",\"key\":\"graduation_running\",\"value\":\"0\",\"source\":\"host watcher\"}" \ + >/dev/null 2>&1 || true +curl -sSf -X POST "$ATOCORE_URL/project/state" \ + -H 'Content-Type: application/json' \ + -d "{\"project\":\"atocore\",\"category\":\"status\",\"key\":\"graduation_last_finished_at\",\"value\":\"$FINISH_TS\",\"source\":\"host watcher\"}" \ + >/dev/null 2>&1 || true + +SAFE_RESULT=$(printf '%s' "$RESULT" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read())[1:-1])") +curl -sSf -X POST "$ATOCORE_URL/project/state" \ + -H 'Content-Type: application/json' \ + -d "{\"project\":\"atocore\",\"category\":\"status\",\"key\":\"graduation_last_result\",\"value\":\"$SAFE_RESULT\",\"source\":\"host watcher\"}" \ + >/dev/null 2>&1 || true diff --git a/src/atocore/api/routes.py b/src/atocore/api/routes.py index 30aa98e..3487994 100644 --- a/src/atocore/api/routes.py +++ b/src/atocore/api/routes.py @@ -1358,6 +1358,77 @@ def api_resolve_conflict(conflict_id: str, req: ConflictResolveRequest) -> dict: return {"status": "resolved", "id": conflict_id, "action": req.action} +class GraduationRequestBody(BaseModel): + project: str = "" + limit: int = 30 + + +@router.post("/admin/graduation/request") +def api_request_graduation(req: GraduationRequestBody) -> dict: + """Request a host-side memory-graduation run. + + Writes a flag in project_state with project + limit. A host cron + watcher picks it up within ~2 min and runs graduate_memories.py. + Mirrors the /admin/triage/request-drain pattern (bridges container + β†’ host because claude CLI lives on host, not container). + """ + from datetime import datetime as _dt, timezone as _tz + from atocore.context.project_state import set_state + + now = _dt.now(_tz.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + payload = json.dumps({ + "project": (req.project or "").strip(), + "limit": max(1, min(req.limit, 500)), + "requested_at": now, + }) + set_state( + project_name="atocore", + category="config", + key="graduation_requested_at", + value=payload, + source="admin ui", + ) + return { + "requested_at": now, + "project": req.project, + "limit": req.limit, + "note": "Host watcher picks up within ~2 min. Poll /admin/graduation/status for progress.", + } + + +@router.get("/admin/graduation/status") +def api_graduation_status() -> dict: + """State of the graduation pipeline (UI polling).""" + from atocore.context.project_state import get_state + out = { + "requested": None, + "last_started_at": None, + "last_finished_at": None, + "last_result": None, + "is_running": False, + } + try: + for e in get_state("atocore"): + if e.category != "config" and e.category != "status": + continue + if e.key == "graduation_requested_at": + try: + out["requested"] = json.loads(e.value) + except Exception: + out["requested"] = {"raw": e.value} + elif e.key == "graduation_last_started_at": + out["last_started_at"] = e.value + elif e.key == "graduation_last_finished_at": + out["last_finished_at"] = e.value + elif e.key == "graduation_last_result": + out["last_result"] = e.value + elif e.key == "graduation_running": + out["is_running"] = (e.value == "1") + except Exception: + pass + return out + + @router.get("/admin/graduation/stats") def api_graduation_stats() -> dict: """Phase 5F graduation stats for dashboard.""" diff --git a/src/atocore/engineering/triage_ui.py b/src/atocore/engineering/triage_ui.py index 352e23d..696e329 100644 --- a/src/atocore/engineering/triage_ui.py +++ b/src/atocore/engineering/triage_ui.py @@ -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 = '' + "".join( + f'' + for p in projects + ) + except Exception: + options = '' + + return f""" +
+ + + + + Scans active memories, asks the LLM "does this describe a typed entity?", + and creates entity candidates. Review them in the Entity section below. + +
+""" + + +_GRADUATION_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"""

Triage Queue

+ {graduation_bar}

πŸŽ‰ No candidates to review.

The auto-triage pipeline keeps this queue empty unless something needs your judgment.

+

Use the πŸŽ“ Graduate memories button above to propose new entity candidates from existing memories.

- """ + """ + _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 - 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. + {graduation_bar}

πŸ“ Memory Candidates ({len(mem_candidates)})

{mem_cards} {ent_cards_html} - """ + _TRIAGE_SCRIPT + _ENTITY_TRIAGE_SCRIPT + """ + _TRIAGE_SCRIPT + _ENTITY_TRIAGE_SCRIPT + _GRADUATION_SCRIPT return render_html( "Triage β€” AtoCore",