Earned auto-apply (fire + observability + UI), retrain cadences, Explore arrow-nav #143

Merged
bvandeusen merged 8 commits from dev into main 2026-06-29 07:30:41 -04:00
4 changed files with 136 additions and 8 deletions
Showing only changes of commit 77baee49fd - Show all commits
+4
View File
@@ -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
+30
View File
@@ -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"
@@ -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 }
}
+35 -8
View File
@@ -30,11 +30,22 @@
>
<img :src="c.thumbnail_url" alt="" loading="lazy" />
</button>
<v-btn
class="fc-ex__reseed" size="small" variant="text" color="accent"
prepend-icon="mdi-shuffle-variant" :loading="seeding"
@click="reseed"
>Random image</v-btn>
<div class="fc-ex__trail-actions">
<!-- Active retrain right where you tag: fold the +/- you just gave
into the heads without a trip to Settings (the nightly beat is the
passive cadence). -->
<v-btn
size="small" variant="text" color="accent"
prepend-icon="mdi-brain" :loading="headsBusy || headsRunning"
title="Retrain the concept heads on your latest tags"
@click="trainHeads"
>{{ headsRunning ? 'Training…' : 'Retrain heads' }}</v-btn>
<v-btn
size="small" variant="text" color="accent"
prepend-icon="mdi-shuffle-variant" :loading="seeding"
@click="reseed"
>Random image</v-btn>
</div>
</nav>
<v-alert v-if="store.error" type="error" variant="tonal" class="ma-3">
@@ -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()
})
</script>
<style scoped>
@@ -250,7 +275,9 @@ onUnmounted(() => document.removeEventListener('keydown', onKeyDown))
.fc-ex__crumb img { width: 100%; height: 100%; object-fit: cover; }
.fc-ex__crumb--current { border-color: rgb(var(--v-theme-accent)); }
.fc-ex__crumb:focus-visible { outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px; }
.fc-ex__reseed { margin-left: auto; }
.fc-ex__trail-actions {
margin-left: auto; display: flex; align-items: center; gap: 4px; flex: 0 0 auto;
}
/* The three panes fill the remaining height; each scrolls on its own.
grid-template-rows: minmax(0, 1fr) BOUNDS the single row to the container