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:
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user