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 @@
>
-