From 77baee49fdc3d2fa0803d977c3472e9f7a3e9364 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 28 Jun 2026 22:15:27 -0400 Subject: [PATCH] feat(heads): nightly auto-retrain + inline Retrain button in Explore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cadences for keeping heads in sync with your tagging: - PASSIVE: a nightly `scheduled_train_heads` beat (skips if a run is already in flight; creates+commits the run row before dispatching train_heads so the ml worker always finds it). Folds the day's accepts/rejects + newly-eligible concepts into the heads without anyone clicking. - ACTIVE: a "Retrain heads" button in the Explore trail bar — bank the +/- feedback you just gave while walking content, without a trip to Settings. Shared logic in a new useHeadTraining composable (trigger + poll + start/finish toasts), used by the Explore button; reflects an already-running run (incl. the nightly one) on mount. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa --- backend/app/celery_app.py | 4 ++ backend/app/tasks/ml.py | 30 +++++++++ frontend/src/composables/useHeadTraining.js | 67 +++++++++++++++++++++ frontend/src/views/ExploreView.vue | 43 ++++++++++--- 4 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 frontend/src/composables/useHeadTraining.js diff --git a/backend/app/celery_app.py b/backend/app/celery_app.py index be1b368..f4d3751 100644 --- a/backend/app/celery_app.py +++ b/backend/app/celery_app.py @@ -109,6 +109,10 @@ def make_celery() -> Celery: "task": "backend.app.tasks.ml.apply_allowlist_tags", "schedule": 86400.0, }, + "train-heads-nightly": { + "task": "backend.app.tasks.ml.scheduled_train_heads", + "schedule": 86400.0, # passive cadence; manual retrain stays available + }, "integrity-verify-weekly": { "task": "backend.app.tasks.maintenance.verify_integrity", "schedule": 604800.0, # weekly diff --git a/backend/app/tasks/ml.py b/backend/app/tasks/ml.py index 65d8866..b6366fe 100644 --- a/backend/app/tasks/ml.py +++ b/backend/app/tasks/ml.py @@ -629,3 +629,33 @@ def train_heads(self, run_id: int) -> str: run.finished_at = datetime.now(UTC) session.commit() return "ready" + + +@celery.task(name="backend.app.tasks.ml.scheduled_train_heads") +def scheduled_train_heads() -> str: + """Nightly passive retrain (#114): fold the day's accepts/rejects + any + newly-eligible concepts into the heads without the operator clicking. Skips + if a run is already in flight (one at a time). Creates + COMMITS the run row + before dispatching so the ml-queue worker can always find it.""" + from datetime import UTC, datetime + + from sqlalchemy import select as sa_select + + from ..models import HeadTrainingRun + + SessionLocal = _sync_session_factory() + with SessionLocal() as session: + running = session.execute( + sa_select(HeadTrainingRun.id).where(HeadTrainingRun.status == "running") + ).scalar_one_or_none() + if running is not None: + return "already running" + run = HeadTrainingRun( + params={"source": "scheduled"}, status="running", + last_progress_at=datetime.now(UTC), + ) + session.add(run) + session.commit() + run_id = run.id + train_heads.delay(run_id) + return "dispatched" diff --git a/frontend/src/composables/useHeadTraining.js b/frontend/src/composables/useHeadTraining.js new file mode 100644 index 0000000..8539649 --- /dev/null +++ b/frontend/src/composables/useHeadTraining.js @@ -0,0 +1,67 @@ +import { computed, ref } from 'vue' + +import { useHeadsStore } from '../stores/heads.js' +import { toast } from '../utils/toast.js' + +// Shared "(re)train the concept heads" behaviour (#114): trigger + poll status, +// with toasts on start/finish. Backs both the Settings card and the Explore +// view's inline button so the active retrain works the same everywhere. +// Call start() on mount and stop() on unmount to manage the poll timer. +export function useHeadTraining() { + const store = useHeadsStore() + const summary = ref(null) + const busy = ref(false) // the trigger POST is in flight + let timer = null + + const running = computed(() => summary.value?.running_id != null) + const headCount = computed(() => summary.value?.head_count ?? 0) + + async function refresh() { + try { + summary.value = await store.status() + } catch { /* non-fatal — the button still offers a fresh train */ } + } + + function startPoll() { + stopPoll() + timer = setInterval(async () => { + const wasRunning = running.value + await refresh() + // Announce completion once, on the running → done transition. + if (wasRunning && !running.value) { + toast({ + text: `Heads retrained — ${headCount.value} concept${headCount.value === 1 ? '' : 's'}`, + type: 'success', + }) + } + if (!running.value) stopPoll() + }, 5000) + } + + function stopPoll() { + if (timer) { clearInterval(timer); timer = null } + } + + // Reflect an already-running run (e.g. the nightly one) on mount. + async function start() { + await refresh() + if (running.value) startPoll() + } + + async function train() { + busy.value = true + try { + await store.train() + toast({ text: 'Head training started…', type: 'info' }) + await refresh() + startPoll() + } catch (e) { + const msg = e.body?.running_id ? 'Training is already running.' : e.message + toast({ text: `Could not start training: ${msg}`, type: 'error' }) + } finally { + busy.value = false + } + } + + return { summary, running, busy, headCount, refresh, train, start, stop: stopPoll } +} diff --git a/frontend/src/views/ExploreView.vue b/frontend/src/views/ExploreView.vue index 67ea039..5a3052f 100644 --- a/frontend/src/views/ExploreView.vue +++ b/frontend/src/views/ExploreView.vue @@ -30,11 +30,22 @@ > - Random image +
+ + {{ headsRunning ? 'Training…' : 'Retrain heads' }} + Random image +
@@ -115,6 +126,7 @@ import { useRoute, useRouter } from 'vue-router' import { useApi } from '../composables/useApi.js' import { useExploreStore } from '../stores/explore.js' import { useModalStore } from '../stores/modal.js' +import { useHeadTraining } from '../composables/useHeadTraining.js' import { isTextEntry } from '../utils/textEntry.js' import ImageCanvas from '../components/modal/ImageCanvas.vue' import ImageMetaBar from '../components/modal/ImageMetaBar.vue' @@ -132,6 +144,13 @@ const seeding = ref(false) const seedError = ref(null) const tagPanelRef = ref(null) +// Inline head-retrain (shared with the Settings card) so banking your latest +// +/- feedback is one click while you walk content. +const { + running: headsRunning, busy: headsBusy, train: trainHeads, + start: startHeads, stop: stopHeads, +} = useHeadTraining() + // Auto-focus the tag input after any action so tagging needs no extra click — // the whole point of the workspace (operator-asked 2026-06-26). nextTick waits // for the post-navigation re-render, then rAF lands the focus AFTER paint so a @@ -216,8 +235,14 @@ function onKeyDown (ev) { const id = ev.key === 'ArrowLeft' ? store.backTarget() : store.forwardTarget() if (id != null) { ev.preventDefault(); goTo(id) } } -onMounted(() => document.addEventListener('keydown', onKeyDown)) -onUnmounted(() => document.removeEventListener('keydown', onKeyDown)) +onMounted(() => { + document.addEventListener('keydown', onKeyDown) + startHeads() // reflect a run already in flight (e.g. the nightly one) +}) +onUnmounted(() => { + document.removeEventListener('keydown', onKeyDown) + stopHeads() +})